g1t/apps/web/app/routes/workspace/billing.tsx
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.
| Paid features: a workspace turns on Deployments with a monthly plan | 1 | import { CreditCard, Rocket } from "lucide-react"; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 2 | import { Form, Link, data, redirect, useNavigation } from "react-router"; |
| 3 | ||
| Deployments: a preview for every pull request, production on g1t.page | 4 | import { |
| 5 | DEPLOYMENTS_ALLOWANCE, | |
| 6 | MICROS_PER_DOLLAR, | |
| 7 | type DeployUsage, | |
| 8 | type Feature, | |
| 9 | type FeatureState, | |
| Usage limits: unpaid usage can only go so far | 10 | type Limit, |
| Deployments: a preview for every pull request, production on g1t.page | 11 | } from "@g1t/contracts"; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 12 | |
| 13 | import type { Route } from "./+types/billing"; | |
| 14 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| Deployments: a preview for every pull request, production on g1t.page | 15 | import { billing, deployments } from "../../lib/services.server"; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 16 | import { |
| 17 | assertSameOrigin, | |
| 18 | getViewer, | |
| 19 | requireUser, | |
| 20 | roleIn, | |
| 21 | unwrap, | |
| 22 | } from "../../lib/session.server"; | |
| 23 | ||
| 24 | /** What can be added in one payment, in dollars. */ | |
| 25 | const AMOUNTS = [10, 25, 50, 100]; | |
| 26 | ||
| 27 | export function meta({ params }: Route.MetaArgs) { | |
| 28 | return [{ title: `Billing · ${params.owner} · g1t` }]; | |
| 29 | } | |
| 30 | ||
| 31 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 32 | const viewer = getViewer(context); | |
| 33 | const role = roleIn(viewer, params.owner); | |
| 34 | // Members only; to anyone else the page does not exist. | |
| 35 | if (!role) throw data(null, { status: 404 }); | |
| 36 | const slug = params.owner.toLowerCase(); | |
| 37 | ||
| 38 | // Back from the payment page: credit it, then drop the id from the address. | |
| 39 | const url = new URL(request.url); | |
| 40 | const session = url.searchParams.get("session"); | |
| 41 | if (session) { | |
| Paid features: a workspace turns on Deployments with a monthly plan | 42 | // The same page returns from both a credit payment and a plan. |
| 43 | if (url.searchParams.get("plan")) { | |
| 44 | await billing.confirmSubscription(slug, viewer, session); | |
| 45 | throw redirect(`/${slug}/-/billing?subscribed=1`); | |
| 46 | } | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 47 | await billing.confirm(slug, viewer, session); |
| 48 | throw redirect(`/${slug}/-/billing?added=1`); | |
| 49 | } | |
| Usage limits: unpaid usage can only go so far | 50 | const [account, ledger, features, deployUsage, limit] = await Promise.all([ |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 51 | billing.account(slug, viewer), |
| 52 | billing.ledger(slug, viewer), | |
| Paid features: a workspace turns on Deployments with a monthly plan | 53 | billing.features(slug, viewer), |
| Deployments: a preview for every pull request, production on g1t.page | 54 | deployments.usage(slug, viewer), |
| Usage limits: unpaid usage can only go so far | 55 | billing.limit(slug, viewer), |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 56 | ]); |
| 57 | return { | |
| 58 | slug, | |
| 59 | role, | |
| 60 | account: unwrap(account), | |
| 61 | ledger: unwrap(ledger), | |
| Paid features: a workspace turns on Deployments with a monthly plan | 62 | features: unwrap(features), |
| Deployments: a preview for every pull request, production on g1t.page | 63 | deployUsage: deployUsage.ok ? deployUsage.value : null, |
| Usage limits: unpaid usage can only go so far | 64 | limit: limit.ok ? limit.value : null, |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 65 | added: url.searchParams.has("added"), |
| Paid features: a workspace turns on Deployments with a monthly plan | 66 | subscribed: url.searchParams.has("subscribed"), |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 67 | }; |
| 68 | } | |
| 69 | ||
| 70 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 71 | assertSameOrigin(request); | |
| 72 | const user = requireUser(context, request); | |
| 73 | const form = await request.formData(); | |
| Paid features: a workspace turns on Deployments with a monthly plan | 74 | const page = `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`; |
| 75 | const intent = form.get("intent"); | |
| Usage limits: unpaid usage can only go so far | 76 | if (intent === "spend-limit" || intent === "no-spend-limit") { |
| 77 | const amount = Number(form.get("limit")); | |
| 78 | if (intent === "spend-limit" && !(Number.isFinite(amount) && amount >= 0)) { | |
| 79 | return { error: "A spend limit is a dollar amount." }; | |
| 80 | } | |
| 81 | const set = await billing.setSpendLimit( | |
| 82 | user, | |
| 83 | params.owner, | |
| 84 | intent === "spend-limit" ? Math.round(amount * MICROS_PER_DOLLAR) : null, | |
| 85 | ); | |
| 86 | return set.ok ? null : { error: set.error.message }; | |
| 87 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 88 | if (intent === "subscribe" || intent === "cancel" || intent === "resume") { |
| 89 | const feature = String(form.get("feature")) as Feature; | |
| 90 | if (intent !== "subscribe") { | |
| 91 | const changed = await billing.cancelSubscription(user, params.owner, feature, intent === "resume"); | |
| 92 | return changed.ok ? null : { error: changed.error.message }; | |
| 93 | } | |
| 94 | const started = await billing.subscribe(user, params.owner, feature, `${page}?plan=${feature}`); | |
| 95 | if (!started.ok) return { error: started.error.message }; | |
| 96 | throw redirect(started.value.url); | |
| 97 | } | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 98 | const dollars = Math.trunc(Number(form.get("amount"))); |
| 99 | const started = await billing.checkout( | |
| 100 | user, | |
| 101 | params.owner, | |
| 102 | Number.isFinite(dollars) ? dollars * 100 : 0, | |
| 103 | // Spelled out: a form post arrives at a data address, not the page's. | |
| 104 | `${new URL(request.url).origin}/${params.owner.toLowerCase()}/-/billing`, | |
| 105 | ); | |
| 106 | if (!started.ok) return { error: started.error.message }; | |
| 107 | throw redirect(started.value.url); | |
| 108 | } | |
| 109 | ||
| 110 | /** Millionths of a dollar as dollars, to the cent or finer. */ | |
| 111 | function dollars(micros: number, digits = 2): string { | |
| 112 | const sign = micros < 0 ? "−" : ""; | |
| 113 | return `${sign}$${(Math.abs(micros) / MICROS_PER_DOLLAR).toFixed(digits)}`; | |
| 114 | } | |
| 115 | ||
| 116 | export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) { | |
| Usage limits: unpaid usage can only go so far | 117 | const { slug, role, account, ledger, features, deployUsage, limit, added, subscribed } = loaderData; |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 118 | const { status } = account; |
| 119 | const paying = useNavigation().state === "submitting"; | |
| 120 | const empty = account.balanceMicros <= 0; | |
| 121 | return ( | |
| 122 | <div className="grid gap-10 lg:grid-cols-[1fr_20rem]"> | |
| 123 | <div className="min-w-0"> | |
| Free while g1t is being built out; agents can check out their own forks | 124 | {status.free && ( |
| 125 | <div className="mb-8 rounded-xl border border-accent/30 bg-accent/5 p-5"> | |
| 126 | <h2 className="font-medium">Free while g1t is being built out</h2> | |
| 127 | <p className="mt-1.5 max-w-2xl text-sm text-muted"> | |
| 128 | While we build g1t out, using it costs nothing: agents, reviews, checks and workflows. Bring your own | |
| 129 | model provider under Integrations and its usage is billed by that provider, not by g1t. Runs are still | |
| 130 | recorded with what they cost, so Usage shows what you are using. This is for now, not forever: pricing | |
| 131 | will come later, and we will say so well before anything is charged. | |
| 132 | </p> | |
| 133 | </div> | |
| 134 | )} | |
| Usage limits: unpaid usage can only go so far | 135 | {limit && account.status.enabled && ( |
| 136 | <LimitCard limit={limit} owner={role === "owner"} busy={paying} error={actionData?.error} /> | |
| 137 | )} | |
| 138 | ||
| Paid features: a workspace turns on Deployments with a monthly plan | 139 | <h2 className="font-medium">Plans</h2> |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 140 | <p className="mt-1 max-w-2xl text-sm text-muted"> |
| Paid features: a workspace turns on Deployments with a monthly plan | 141 | Paid features are turned on per workspace with a monthly plan. They are never free, including while the rest of |
| 142 | g1t is. | |
| 143 | </p> | |
| 144 | {subscribed && <p className="mt-3 text-sm text-accent">Payment received. The plan is on.</p>} | |
| 145 | <div className="mt-5 space-y-4"> | |
| 146 | {features.map((state) => ( | |
| 147 | <PlanCard | |
| 148 | key={state.plan.feature} | |
| 149 | state={state} | |
| 150 | owner={role === "owner"} | |
| 151 | enabled={account.status.enabled} | |
| 152 | live={account.status.live} | |
| 153 | busy={paying} | |
| Deployments: a preview for every pull request, production on g1t.page | 154 | usage={state.plan.feature === "deployments" ? deployUsage : null} |
| Paid features: a workspace turns on Deployments with a monthly plan | 155 | /> |
| 156 | ))} | |
| 157 | </div> | |
| 158 | <div className="mt-2"> | |
| 159 | <ErrorText>{actionData?.error}</ErrorText> | |
| 160 | </div> | |
| 161 | ||
| 162 | <h2 className="mt-12 font-medium">Agent credit</h2> | |
| 163 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 164 | g1t agents that work on this workspace's repositories are paid for from |
| Usage limits: unpaid usage can only go so far | 165 | its credit: what the model cost, plus {account.marginPercent}%. Every sandbox, for agents, checks, the merge |
| 166 | queue and workflows, is metered by the second past 500 free minutes a month. With no credit, agents do not | |
| 167 | start. | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 168 | </p> |
| 169 | ||
| 170 | <div | |
| 171 | className={`mt-5 rounded-xl border p-5 ${ | |
| 172 | empty && status.enabled ? "border-warn/40 bg-warn/5" : "border-line bg-surface" | |
| 173 | }`} | |
| 174 | > | |
| 175 | <p className="text-xs text-muted">Balance</p> | |
| 176 | <p className="mt-1 text-3xl font-semibold tabular-nums tracking-tight"> | |
| 177 | {dollars(account.balanceMicros)} | |
| 178 | </p> | |
| 179 | {added && ( | |
| 180 | <p className="mt-2 text-sm text-accent">Payment received. Credit added.</p> | |
| 181 | )} | |
| Paid features: a workspace turns on Deployments with a monthly plan | 182 | {status.free && !features.some((state) => state.on && state.subscription) ? ( |
| Billing: nothing to add while it is free | 183 | <p className="mt-3 text-sm text-muted"> |
| 184 | Nothing to add for now: runs are free while g1t is being built out. Credit already here stays for when | |
| 185 | pricing starts. | |
| 186 | </p> | |
| 187 | ) : !status.enabled ? ( | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 188 | <p className="mt-3 text-sm text-muted"> |
| 189 | Payments are not set up on this g1t yet, so nothing is charged and | |
| 190 | agents are limited to selected accounts. | |
| 191 | </p> | |
| 192 | ) : role === "owner" ? ( | |
| 193 | <Form method="post" className="mt-4"> | |
| Paid features: a workspace turns on Deployments with a monthly plan | 194 | <p className="text-sm text-muted"> |
| 195 | {status.free | |
| 196 | ? "Agents are free for now; credit pays for plan usage past its allowance. Add credit by card:" | |
| 197 | : "Add credit by card:"} | |
| 198 | </p> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 199 | <div className="mt-2 flex flex-wrap gap-2"> |
| 200 | {AMOUNTS.map((amount) => ( | |
| 201 | <Button | |
| 202 | key={amount} | |
| 203 | variant={amount === 25 ? "accent" : "quiet"} | |
| 204 | type="submit" | |
| 205 | name="amount" | |
| 206 | value={amount} | |
| 207 | disabled={paying} | |
| 208 | > | |
| 209 | <CreditCard size={14} />${amount} | |
| 210 | </Button> | |
| 211 | ))} | |
| 212 | </div> | |
| 213 | {!status.live && ( | |
| 214 | <p className="mt-3 text-xs text-faint"> | |
| 215 | Payments are in test mode. No real card is charged; use Stripe's | |
| 216 | test card 4242 4242 4242 4242 with any future date and any code. | |
| 217 | </p> | |
| 218 | )} | |
| 219 | </Form> | |
| 220 | ) : ( | |
| 221 | <p className="mt-3 text-sm text-muted">An owner can add credit.</p> | |
| 222 | )} | |
| 223 | </div> | |
| 224 | ||
| 225 | <h3 className="mt-10 text-sm font-medium text-muted">Statement</h3> | |
| 226 | <div className="mt-3"> | |
| 227 | {ledger.length === 0 ? ( | |
| 228 | <EmptyState title="Nothing yet"> | |
| 229 | Each agent run and each payment appears here. | |
| 230 | </EmptyState> | |
| 231 | ) : ( | |
| 232 | <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line"> | |
| 233 | {ledger.map((entry) => ( | |
| 234 | <li key={entry.id} className="flex items-center gap-4 px-4 py-3 text-sm"> | |
| 235 | <div className="min-w-0 grow"> | |
| 236 | {entry.repo && entry.number ? ( | |
| 237 | <Link | |
| 238 | to={`/${entry.repo}/pull/${entry.number}`} | |
| 239 | className="block truncate font-medium hover:underline" | |
| 240 | > | |
| 241 | {entry.description} | |
| 242 | </Link> | |
| 243 | ) : ( | |
| 244 | <p className="truncate font-medium">{entry.description}</p> | |
| 245 | )} | |
| 246 | <p className="mt-0.5 text-xs text-faint"> | |
| 247 | <TimeAgo at={entry.createdAt} /> | |
| 248 | {entry.model && ` · ${entry.model}`} | |
| 249 | {entry.createdBy && ` · ${entry.createdBy}`} | |
| 250 | </p> | |
| 251 | </div> | |
| 252 | <span | |
| 253 | className={`shrink-0 font-mono text-sm tabular-nums ${ | |
| 254 | entry.amountMicros > 0 ? "text-accent" : "text-muted" | |
| 255 | }`} | |
| 256 | > | |
| 257 | {entry.amountMicros > 0 && "+"} | |
| 258 | {dollars(entry.amountMicros, entry.kind === "usage" ? 4 : 2)} | |
| 259 | </span> | |
| 260 | </li> | |
| 261 | ))} | |
| 262 | </ul> | |
| 263 | )} | |
| 264 | </div> | |
| 265 | </div> | |
| 266 | ||
| 267 | <aside className="space-y-5 text-sm"> | |
| 268 | <section className="rounded-xl border border-line bg-surface p-5"> | |
| 269 | <h3 className="font-medium">How it is charged</h3> | |
| 270 | <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted"> | |
| 271 | <li> | |
| 272 | Each run is charged when it finishes, to the workspace that owns the | |
| 273 | repository, whoever assigned the issue. | |
| 274 | </li> | |
| 275 | <li> | |
| 276 | A change, a review, a revision and a catch-up are each a run. The | |
| 277 | statement links each to its pull request. | |
| 278 | </li> | |
| 279 | <li> | |
| 280 | Every pull request's session ends with what that run cost before | |
| 281 | the margin. | |
| 282 | </li> | |
| 283 | <li> | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 284 | With your own model provider, connected under{" "} |
| 285 | <Link to={`/${slug}/-/integrations`} className="text-fg hover:underline"> | |
| 286 | Integrations | |
| 287 | </Link> | |
| 288 | , the provider bills you for the model and each run here is a flat{" "} | |
| New workspaces are told to connect a model, before anything fails | 289 | {dollars(account.orchestrationFeeMicros)} |
| 290 | {status.free && " once pricing starts; nothing while g1t is being built out"}. | |
| Integrations: your own model provider, alerts that open issues, tickets agents read | 291 | </li> |
| 292 | <li> | |
| Agents as a team: lifecycle, merge queue, billing and a new shell | 293 | Only members of <span className="font-mono text-fg">{slug}</span> can |
| 294 | put agents to work on its repositories. | |
| 295 | </li> | |
| 296 | </ul> | |
| 297 | </section> | |
| 298 | </aside> | |
| 299 | </div> | |
| 300 | ); | |
| 301 | } | |
| Paid features: a workspace turns on Deployments with a monthly plan | 302 | |
| 303 | /** A paid feature: what it costs and includes, and its plan. */ | |
| 304 | function PlanCard({ | |
| 305 | state, | |
| 306 | owner, | |
| 307 | enabled, | |
| 308 | live, | |
| 309 | busy, | |
| Deployments: a preview for every pull request, production on g1t.page | 310 | usage, |
| Paid features: a workspace turns on Deployments with a monthly plan | 311 | }: { |
| 312 | state: FeatureState; | |
| 313 | owner: boolean; | |
| 314 | enabled: boolean; | |
| 315 | live: boolean; | |
| 316 | busy: boolean; | |
| Deployments: a preview for every pull request, production on g1t.page | 317 | usage: DeployUsage | null; |
| Paid features: a workspace turns on Deployments with a monthly plan | 318 | }) { |
| 319 | const { plan, subscription } = state; | |
| 320 | const ending = subscription?.status === "canceling"; | |
| 321 | const owed = subscription?.status === "past_due"; | |
| 322 | return ( | |
| 323 | <section | |
| 324 | className={`rounded-xl border p-5 ${ | |
| 325 | state.on && subscription | |
| 326 | ? "border-accent/40 bg-accent/5" | |
| 327 | : owed | |
| 328 | ? "border-warn/40 bg-warn/5" | |
| 329 | : "border-line bg-surface" | |
| 330 | }`} | |
| 331 | > | |
| 332 | <div className="flex flex-wrap items-start justify-between gap-4"> | |
| 333 | <div className="min-w-0"> | |
| 334 | <h3 className="flex flex-wrap items-center gap-2 font-medium"> | |
| 335 | <Rocket size={15} className="text-accent" /> | |
| 336 | {plan.title} | |
| 337 | {state.on && subscription && ( | |
| 338 | <span className="rounded-full bg-accent/15 px-2 py-0.5 text-xs text-accent"> | |
| 339 | {ending ? "Ends " : "On"} | |
| 340 | {subscription.periodEnd && ( | |
| 341 | <> | |
| 342 | {ending ? "" : " · renews "} | |
| 343 | {new Date(subscription.periodEnd).toLocaleDateString()} | |
| 344 | </> | |
| 345 | )} | |
| 346 | </span> | |
| 347 | )} | |
| 348 | {owed && <span className="rounded-full bg-warn/15 px-2 py-0.5 text-xs text-warn">Payment failed</span>} | |
| 349 | </h3> | |
| 350 | <p className="mt-1 text-sm text-muted"> | |
| 351 | Every pull request gets a live preview on g1t.page, and the default branch goes to production on merge. | |
| 352 | </p> | |
| 353 | </div> | |
| 354 | <p className="shrink-0 text-right"> | |
| 355 | <span className="text-2xl font-semibold tabular-nums tracking-tight"> | |
| 356 | ${(plan.monthlyCents / 100).toFixed(0)} | |
| 357 | </span> | |
| 358 | <span className="text-sm text-muted"> / month</span> | |
| 359 | </p> | |
| 360 | </div> | |
| 361 | <ul className="mt-4 grid gap-1.5 text-sm text-muted sm:grid-cols-2"> | |
| 362 | {plan.includes.map((line) => ( | |
| 363 | <li key={line} className="flex gap-2"> | |
| 364 | <span className="text-accent">✓</span> | |
| 365 | {line} | |
| 366 | </li> | |
| 367 | ))} | |
| 368 | </ul> | |
| 369 | <p className="mt-3 text-xs text-faint">{plan.overage}</p> | |
| Deployments: a preview for every pull request, production on g1t.page | 370 | {state.on && usage && <DeployMeter usage={usage} />} |
| Paid features: a workspace turns on Deployments with a monthly plan | 371 | {!enabled ? ( |
| 372 | <p className="mt-4 text-sm text-muted">Payments are not set up on this g1t, so {plan.title} is already on.</p> | |
| 373 | ) : !owner ? ( | |
| 374 | !state.on && <p className="mt-4 text-sm text-muted">An owner can turn it on.</p> | |
| 375 | ) : ( | |
| 376 | <Form method="post" className="mt-4 flex flex-wrap items-center gap-3"> | |
| 377 | <input type="hidden" name="feature" value={plan.feature} /> | |
| 378 | {!state.on || !subscription ? ( | |
| 379 | <Button variant="accent" type="submit" name="intent" value="subscribe" disabled={busy}> | |
| 380 | <CreditCard size={14} /> | |
| 381 | Turn on {plan.title} | |
| 382 | </Button> | |
| 383 | ) : ending ? ( | |
| 384 | <Button variant="quiet" type="submit" name="intent" value="resume" disabled={busy}> | |
| 385 | Keep {plan.title} | |
| 386 | </Button> | |
| 387 | ) : ( | |
| 388 | <Button variant="quiet" type="submit" name="intent" value="cancel" disabled={busy}> | |
| 389 | Turn off at the end of the period | |
| 390 | </Button> | |
| 391 | )} | |
| 392 | {!live && !state.on && ( | |
| 393 | <span className="text-xs text-faint">Test mode: card 4242 4242 4242 4242, any future date and code.</span> | |
| 394 | )} | |
| 395 | </Form> | |
| 396 | )} | |
| 397 | </section> | |
| 398 | ); | |
| 399 | } | |
| Deployments: a preview for every pull request, production on g1t.page | 400 | |
| 401 | /** This month's use of the Deployments plan against what it includes. */ | |
| 402 | function DeployMeter({ usage }: { usage: DeployUsage }) { | |
| 403 | const a = DEPLOYMENTS_ALLOWANCE; | |
| 404 | const rows: [string, number, number, (n: number) => string][] = [ | |
| 405 | ["Apps up at once (most this month)", usage.peakApps, a.apps, (n) => String(n)], | |
| 406 | ["Requests", usage.requests, a.requests, (n) => n.toLocaleString("en-US")], | |
| 407 | ["CPU milliseconds", usage.cpuMs, a.cpuMs, (n) => n.toLocaleString("en-US")], | |
| 408 | ]; | |
| 409 | return ( | |
| 410 | <div className="mt-4 rounded-lg border border-line bg-bg/40 p-4"> | |
| 411 | <p className="text-xs font-medium text-muted">This month ({usage.month})</p> | |
| 412 | <ul className="mt-2 space-y-2.5"> | |
| 413 | {rows.map(([label, used, included, show]) => ( | |
| 414 | <li key={label} className="text-sm"> | |
| 415 | <div className="flex justify-between gap-4"> | |
| 416 | <span className="text-muted">{label}</span> | |
| 417 | <span className={`tabular-nums ${used > included ? "text-warn" : ""}`}> | |
| 418 | {show(used)} <span className="text-faint">of {show(included)}</span> | |
| 419 | </span> | |
| 420 | </div> | |
| 421 | <div className="mt-1 h-1 overflow-hidden rounded-full bg-line"> | |
| 422 | <div | |
| 423 | className={`h-full rounded-full ${used > included ? "bg-warn" : "bg-accent"}`} | |
| 424 | style={{ width: `${Math.min(100, (used / included) * 100)}%` }} | |
| 425 | /> | |
| 426 | </div> | |
| 427 | </li> | |
| 428 | ))} | |
| 429 | </ul> | |
| 430 | <p className="mt-3 text-xs text-faint"> | |
| 431 | Builds: {Math.ceil(usage.buildSeconds / 60)} min, {dollars(usage.buildMicros, 4)} at cost. | |
| 432 | {usage.countedAt ? " Requests and CPU time are counted every few minutes." : " Requests are counted once apps get visits."} | |
| 433 | </p> | |
| 434 | </div> | |
| 435 | ); | |
| 436 | } | |
| Usage limits: unpaid usage can only go so far | 437 | |
| 438 | const TRUST: Record<Limit["trust"], { label: string; detail: string }> = { | |
| 439 | new: { | |
| 440 | label: "New", | |
| 441 | detail: "No payment to g1t yet, so the limit is small: the free allowances and a little more. It grows once the workspace pays.", | |
| 442 | }, | |
| 443 | paid: { label: "Paid", detail: "Twice what the workspace has paid g1t, from $25 up to $1,000." }, | |
| 444 | reviewed: { label: "Reviewed", detail: "Set by g1t for this workspace." }, | |
| 445 | internal: { label: "g1t", detail: "One of g1t's own workspaces: no limit." }, | |
| 446 | }; | |
| 447 | ||
| 448 | /** | |
| 449 | * How far this month's unpaid usage has gone, and where work stops: g1t's | |
| 450 | * ceiling for the workspace, or the owners' own spend limit if lower. | |
| 451 | */ | |
| 452 | function LimitCard({ limit, owner, busy, error }: { limit: Limit; owner: boolean; busy: boolean; error?: string }) { | |
| 453 | const ceiling = limit.ceilingMicros; | |
| 454 | const share = ceiling ? Math.min(1, limit.exposureMicros / Math.max(ceiling, 1)) : 0; | |
| 455 | const tone = | |
| 456 | limit.state === "stopped" ? "border-danger/40 bg-danger/5" : limit.state === "warning" ? "border-warn/40 bg-warn/5" : "border-line bg-surface"; | |
| 457 | const bar = limit.state === "stopped" ? "bg-danger" : limit.state === "warning" ? "bg-warn" : "bg-accent"; | |
| 458 | const trust = TRUST[limit.trust]; | |
| 459 | return ( | |
| 460 | <section className={`mb-10 rounded-xl border p-5 ${tone}`}> | |
| 461 | <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 462 | <h2 className="font-medium">Usage limit</h2> | |
| 463 | <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">{trust.label}</span> | |
| 464 | </div> | |
| 465 | <p className="mt-1 max-w-2xl text-sm text-muted"> | |
| 466 | What this month's usage cost g1t, or is charged, whichever is more, less what was paid this month. At the limit, | |
| 467 | new sandboxes and builds stop and apps pause until the workspace pays or the month turns. Work already running | |
| 468 | finishes. | |
| 469 | </p> | |
| 470 | <p className="mt-4 text-2xl font-semibold tabular-nums tracking-tight"> | |
| 471 | {dollars(limit.exposureMicros)} | |
| 472 | <span className="text-base font-normal text-muted"> {ceiling == null ? "· no limit" : `of ${dollars(ceiling)}`}</span> | |
| 473 | </p> | |
| 474 | {ceiling != null && ( | |
| 475 | <div className="mt-3 h-1.5 overflow-hidden rounded-full bg-line" role="presentation"> | |
| 476 | <div className={`h-full ${bar}`} style={{ width: `${Math.max(share * 100, share > 0 ? 2 : 0)}%` }} /> | |
| 477 | </div> | |
| 478 | )} | |
| 479 | {limit.message && <p className="mt-3 text-sm">{limit.message}</p>} | |
| 480 | <p className="mt-3 text-xs text-faint"> | |
| 481 | {trust.detail} | |
| 482 | {limit.trustCeilingMicros != null && limit.spendLimitMicros != null && ` g1t's limit is ${dollars(limit.trustCeilingMicros)}.`} | |
| 483 | </p> | |
| 484 | {owner && limit.trust !== "internal" && ( | |
| 485 | <Form method="post" className="mt-4 flex flex-wrap items-end gap-2"> | |
| 486 | <label className="text-sm"> | |
| 487 | <span className="block text-xs text-muted">Your own monthly spend limit</span> | |
| 488 | <span className="mt-1 flex items-center rounded-md border border-line bg-bg px-2 focus-within:border-accent"> | |
| 489 | <span className="text-muted">$</span> | |
| 490 | <input | |
| 491 | name="limit" | |
| 492 | type="number" | |
| 493 | min={0} | |
| 494 | step={1} | |
| 495 | defaultValue={limit.spendLimitMicros != null ? limit.spendLimitMicros / MICROS_PER_DOLLAR : ""} | |
| 496 | placeholder="None" | |
| 497 | className="w-24 bg-transparent px-1 py-1.5 tabular-nums outline-none" | |
| 498 | /> | |
| 499 | </span> | |
| 500 | </label> | |
| 501 | <Button variant="quiet" type="submit" name="intent" value="spend-limit" disabled={busy}> | |
| 502 | Set | |
| 503 | </Button> | |
| 504 | {limit.spendLimitMicros != null && ( | |
| 505 | <Button variant="quiet" type="submit" name="intent" value="no-spend-limit" disabled={busy}> | |
| 506 | Remove | |
| 507 | </Button> | |
| 508 | )} | |
| 509 | <ErrorText>{error}</ErrorText> | |
| 510 | </Form> | |
| 511 | )} | |
| 512 | </section> | |
| 513 | ); | |
| 514 | } |