Skip to content

g1t/apps/sudo/app/routes/workspace.tsx

652 lines28,313 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.

Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1import { ArrowLeft, Building2, LogOut } from "lucide-react";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales2import type { ReactNode } from "react";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace3import { data, Link, useLocation } from "react-router";
4
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look5import { type AccountSummary, type AdminAction, type Entitlements, type Limit, type Overage, type Terms, httpStatus } from "@g1t/contracts";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace6
7import type { Route } from "./+types/workspace";
8import {
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put9 AllowancesForm,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace10 AuditSection,
11 BillingLinkSection,
12 CreditForm,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging13 CreditsSection,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace14 Figure,
15 LedgerSection,
16 Owners,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look17 PaymentForm,
sudo: reset a test workspace's billing so it starts again as a new customer; refused on a live Stripe key, for comped workspaces and for an enterprise's18 ResetBillingForm,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace19 ReviewPanel,
20 TermsForm,
21} from "~/components/billing";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales22import { MonthsChart } from "~/components/charts";
23import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look24import {
25 Avatar,
26 Badge,
27 Button,
28 EmptyState,
29 ExposureBar,
30 Field,
31 Input,
32 Notice,
33 Section,
34 Select,
35 StateBadge,
36 TermsBadge,
37 Textarea,
38 TrustBadge,
39 When,
40 trustAbout,
41} from "~/components/ui";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace42import { type Subject, billingAction } from "~/lib/billing-actions.server";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales43import { parseSlug, text } from "~/lib/forms";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace44import { usd } from "~/lib/money";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales45import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
46import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look47import { goodwillWarning, spikeLabel } from "~/lib/pricing";
48import { admin, entitlements as entitlementsOf, identity, priceBook } from "~/lib/services.server";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales49import { settle } from "~/lib/settle";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace50import { requireStaff } from "~/lib/staff";
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging51import { fullDiscount } from "~/lib/terms";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace52import type { Enterprise } from "~/lib/workspaces";
53
54export const meta: Route.MetaFunction = ({ loaderData }) => [
55 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
56 { name: "robots", content: "noindex, nofollow" },
57];
58
59/** Whether an audit line is about `slug`, in an enterprise's log. */
60function mentions(action: AdminAction, slug: string): boolean {
61 // A slug is letters, digits and hyphens, none special in a pattern.
62 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
63}
64
65async function load(raw: string) {
66 const parsed = parseSlug(raw);
67 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
68 const slug = parsed.value;
69 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
70 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
71 const detail = result.value;
72 const { account } = detail.summary;
73 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
74 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
75 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
76 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
77 return { slug, person, detail, billedTo, subject };
78}
79
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales80/** Sales and invoices are newer billing methods; a page still opens without them. */
81const SALES_DONE = new Set(["sales", "note"]);
82
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace83export async function loader({ request, params, context }: Route.LoaderArgs) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales84 const staff = requireStaff(context);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace85 const { slug, person, detail, billedTo, subject } = await load(params.slug);
86 const { summary } = detail;
87 // On an enterprise, the limit is the enterprise's and the figures are
88 // this workspace's share of its bill.
89 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
90 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
91 const figures = billedTo
92 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
93 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging94 const [enterprises, sales, invoices, plan, overages, book, credits] = await Promise.all([
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales95 billedTo
96 ? Promise.resolve([])
97 : admin
98 .accounts()
99 .then((rows: AccountSummary[]) =>
100 rows
101 .filter((row) => row.account.kind === "enterprise")
102 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
103 ),
104 settle(admin.sales(slug)),
105 settle(admin.workspaceInvoices(slug)),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look106 settle(entitlementsOf(slug)),
107 settle(admin.overages()),
108 settle(priceBook()),
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging109 settle(admin.credits({ workspace: slug })),
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales110 ]);
111 const done = doneKey(request.url);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace112 return {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales113 me: staff.email,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace114 slug,
115 name: person?.name ?? slug,
116 person,
117 billedTo,
118 terms: subject.terms,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put119 allowances: summary.account.allowances,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace120 limit,
121 figures,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales122 months: summary.months ?? [],
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace123 enterprises,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales124 sales: sales.ok ? sales.value : null,
125 salesError: sales.ok ? null : sales.error,
126 invoices: invoices.ok ? invoices.value : [],
127 invoicesError: invoices.ok ? null : invoices.error,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look128 entitlements: plan.ok ? plan.value : null,
129 entitlementsError: plan.ok ? null : plan.error,
130 overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null,
131 forgiveCap: (book.ok && book.value.free?.overageForgiveCostMicros) || 50_000_000,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging132 credits: credits.ok
133 ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) }
134 : null,
135 creditsError: credits.ok ? null : credits.error,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace136 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
137 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales138 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
139 salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace140 };
141}
142
143export async function action({ request, params, context }: Route.ActionArgs) {
144 const staff = requireStaff(context);
145 // What is acted on comes from billing and identity, not from the form.
146 const { slug, subject } = await load(params.slug);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales147 const path = `/workspaces/${encodeURIComponent(slug)}`;
148 const form = await request.clone().formData();
149 if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
150 return billingAction(request, staff, subject, path);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace151}
152
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales153const SECTIONS = [
154 { id: "members", label: "Members" },
155 { id: "sales", label: "Sales" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look156 { id: "plan", label: "Plan" },
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales157 { id: "billing", label: "Billing" },
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging158 { id: "credits", label: "Credits" },
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales159 { id: "invoices", label: "Invoices" },
160 { id: "ledger", label: "Ledger" },
161 { id: "audit", label: "Audit log" },
162];
163
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace164export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look165 const {
166 me,
167 slug,
168 name,
169 person,
170 billedTo,
171 terms,
172 allowances,
173 limit,
174 figures,
175 months,
176 enterprises,
177 sales,
178 salesError,
179 invoices,
180 invoicesError,
181 entitlements,
182 entitlementsError,
183 overage,
184 forgiveCap,
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging185 credits,
186 creditsError,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look187 ledger,
188 audit,
189 done,
190 salesDone,
191 } = loaderData;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace192 const { pathname } = useLocation();
193 const result = actionData as ActionData | undefined;
194 const review = result && "review" in result ? result.review : null;
195 const link = result && "link" in result ? result.link : null;
196 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
197 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
198
199 return (
200 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales201 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace202 <ArrowLeft size={14} />
203 Workspaces
204 </Link>
205
206 {/* Header */}
207 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
208 <div className="flex min-w-0 items-start gap-3">
209 <Avatar name={slug} size={40} />
210 <div className="min-w-0">
211 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
212 <p className="mt-0.5 text-xs text-faint">
213 <span className="font-mono">{slug}</span>
214 {person && (
215 <>
216 {" · "}created <When at={person.createdAt} />
217 </>
218 )}
219 </p>
220 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
221 <div className="mt-2 flex flex-wrap gap-1.5">
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member222 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace223 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
224 <TermsBadge terms={terms} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging225 {terms.kind !== "standard" && terms.note && <span className="self-center text-xs text-muted">({terms.note})</span>}
226 {!fullDiscount(terms) && <TrustBadge trust={limit.trust} />}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace227 <StateBadge state={limit.state} />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales228 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace229 </div>
230 </div>
231 </div>
232 {owners.length > 0 && (
233 <div className="text-xs">
234 <p className="mb-1.5 text-faint">Owners</p>
235 <Owners owners={owners} />
236 </div>
237 )}
238 </div>
239
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales240 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
241 <ul className="flex gap-1 border-b border-line text-sm">
242 {SECTIONS.map((section) => (
243 <li key={section.id}>
244 <a href={`#${section.id}`} className="block border-b-2 border-transparent px-2.5 py-2 whitespace-nowrap text-muted transition-colors hover:border-line-strong hover:text-fg">
245 {section.label}
246 </a>
247 </li>
248 ))}
249 </ul>
250 </nav>
251
252 <div className="mt-6 space-y-3 empty:hidden">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace253 {done && <Notice tone="ok">{done}</Notice>}
254 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
255 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
256 {review && <ReviewPanel review={review} pathname={pathname} />}
257 </div>
258
259 {/* People */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales260 <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace261 {!person ? (
262 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
263 ) : person.members.length === 0 ? (
264 <EmptyState title="No members" />
265 ) : (
266 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
267 <table className="w-full min-w-120 text-sm">
268 <thead>
269 <tr className="border-b border-line text-left text-xs text-muted">
270 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
271 <th className="px-4 py-2 font-medium">Email</th>
272 <th className="px-4 py-2 font-medium">Role</th>
273 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
274 </tr>
275 </thead>
276 <tbody>
277 {person.members.map((member) => (
278 <tr key={member.username} className="border-b border-line last:border-0">
279 <td className="px-4 py-2.5 sm:pl-5">
280 <span className="inline-flex items-center gap-2 font-mono">
281 <Avatar name={member.username} size={18} square={false} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look282 <Link to={`/users/${member.username}`} className="hover:underline">
283 {member.username}
284 </Link>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace285 </span>
286 </td>
287 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
288 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
289 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
290 <When at={member.joined} />
291 </td>
292 </tr>
293 ))}
294 </tbody>
295 </table>
296 </div>
297 )}
298 </Section>
299
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales300 {/* Sales */}
301 <div className="mt-6">
302 <SalesSection
303 sales={sales}
304 unavailable={salesError}
305 me={me}
306 pathname={pathname}
307 salesError={error("sales")}
308 noteError={error("note")}
309 done={salesDone}
310 />
311 </div>
312
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look313 {/* Plan */}
314 <div className="mt-6">
315 <PlanSection entitlements={entitlements} unavailable={entitlementsError} />
316 </div>
317
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace318 {/* Billing */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales319 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
320 Billing
321 </h2>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace322 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales323 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace324 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
325 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
326 </div>
327
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales328 <Section
329 title="Last six months"
330 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
331 className="mt-6"
332 >
333 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
334 </Section>
335
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace336 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
337 <div className="space-y-6">
338 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
339 {billedTo ? (
340 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
341 <div className="flex flex-wrap items-center justify-between gap-3">
342 <TermsBadge terms={terms} />
343 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
344 Change them on {billedTo.name}
345 </Link>
346 </div>
347 </Section>
348 ) : (
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put349 <>
350 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging351 <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} />
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put352 </>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace353 )}
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging354 <CreditsSection credits={credits} unavailable={creditsError} pathname={pathname} error={error("credits")} />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales355 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
356 <div id="ledger" className="scroll-mt-20">
357 <LedgerSection
358 ledger={ledger}
359 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
360 />
361 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace362 </div>
363
364 <div className="space-y-6">
365 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging366 {!billedTo && !fullDiscount(terms) && (
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look367 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
368 )}
369 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace370 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging371 {!billedTo && !fullDiscount(terms) && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales372 <div id="audit" className="scroll-mt-20">
373 <AuditSection
374 audit={audit}
375 description={
376 billedTo ? (
377 <>
378 Changes about this workspace in {billedTo.name}'s log.{" "}
379 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
380 Full log
381 </Link>
382 </>
383 ) : undefined
384 }
385 />
386 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace387 </div>
388 </div>
389 </main>
390 );
391}
392
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales393/**
394 * The limit, and in words how it is made: trust, the owners' own spend
395 * limit (or the default), the most they may set, and how it grows.
396 */
397function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
398 const lines: { label: string; value: ReactNode }[] = [];
399 if (billedTo) {
400 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging401 } else if (!fullDiscount(terms)) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales402 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
403 }
404 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging405 if (!billedTo && !fullDiscount(terms)) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales406 lines.push({
407 label: "Owners' limit",
408 value: limit.defaultSpendLimit ? (
409 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
410 ) : limit.spendLimitMicros == null ? (
411 "None of their own: they use everything available"
412 ) : (
413 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
414 ),
415 });
416 if (limit.availableMicros !== undefined) {
417 lines.push({
418 label: "Available",
419 value:
420 limit.availableMicros == null ? (
421 "No ceiling"
422 ) : (
423 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
424 ),
425 });
426 }
427 }
428 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
429
430 return (
431 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
432 <p className="mb-2 text-xs text-muted">
433 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
434 </p>
435 <ExposureBar limit={limit} wide />
436 {lines.length > 0 && (
437 <dl className="mt-3 space-y-1 text-xs">
438 {lines.map((line) => (
439 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
440 <dt className="text-faint">{line.label}</dt>
441 <dd className="text-fg-soft">{line.value}</dd>
442 </div>
443 ))}
444 </dl>
445 )}
446 </div>
447 );
448}
449
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace450function BilledToSection({
451 billedTo,
452 enterprises,
453 pathname,
454 error,
455}: {
456 billedTo: Enterprise | null;
457 enterprises: { id: string; name: string; members: number }[];
458 pathname: string;
459 error: SectionError;
460}) {
461 return (
462 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
463 {error && (
464 <div className="mb-4">
465 <Notice tone="error">{error.error}</Notice>
466 </div>
467 )}
468 {billedTo ? (
469 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
470 <p className="text-sm text-muted">
471 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
472 {billedTo.name}
473 </Link>{" "}
474 pays for it, under its limit and terms.
475 </p>
476 <form method="post" action={`${pathname}#review`}>
477 <input type="hidden" name="intent" value="detach" />
478 <Button type="submit" variant="danger">
479 <LogOut size={14} />
480 Move off
481 </Button>
482 </form>
483 </div>
484 ) : enterprises.length === 0 ? (
485 <p className="text-sm text-muted">
486 Itself. There are no enterprises to move it onto yet;{" "}
487 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
488 create one
489 </Link>
490 .
491 </p>
492 ) : (
493 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
494 <input type="hidden" name="intent" value="attach" />
495 <Field label="Itself. Move onto" className="grow">
496 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
497 <option value="" disabled>
498 Choose an enterprise
499 </option>
500 {enterprises.map((enterprise) => (
501 <option key={enterprise.id} value={enterprise.id}>
502 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
503 </option>
504 ))}
505 </Select>
506 </Field>
507 <Button type="submit" variant="quiet">
508 <Building2 size={14} />
509 Move
510 </Button>
511 </form>
512 )}
513 </Section>
514 );
515}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look516
Billing: credits with a kind and expiry, discounts instead of comped, and safer charging517const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (100% discount)", enterprise: "Enterprise" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look518
519function gigabytes(bytes: number): string {
520 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
521}
522
523/** What billing tells every service about the workspace now: plan, caps, pause, trial and what the plan gives it. */
524function PlanSection({ entitlements: e, unavailable }: { entitlements: Entitlements | null; unavailable: string | null }) {
525 if (!e) {
526 return (
527 <Section id="plan" title="Plan and entitlements">
528 <Notice tone="warn">Billing did not answer for entitlements{unavailable ? `: ${unavailable}` : "."}</Notice>
529 </Section>
530 );
531 }
532 const spike = e.spike ? spikeLabel(e.spike.status) : null;
533 const facts: [string, string][] = [
534 ["Compute", e.compute ? "Yes" : e.plan === "free" && e.trialVerified && e.trialMicrosLeft > 0 ? "On the trial" : "No"],
535 ["Card check", e.trialVerified ? "Done" : "Not yet"],
536 ["Trial left", usd(e.trialMicrosLeft)],
537 ["First month", e.firstMonth ? "Yes" : "No"],
538 ["Agents at once", String(e.maxConcurrentAgents)],
539 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
540 ["Run cap", usd(e.runCapMicros)],
541 ["Issue cap", usd(e.issueCapMicros)],
542 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
543 ["Not yet paid", usd(e.exposureMicros)],
544 ["Held by reservations", usd(e.heldMicros ?? 0)],
545 ["Prepaid", usd(e.prepaidMicros ?? 0)],
546 ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"],
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas547 ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look548 ["Open-source pool paid", usd(e.ossPaidMicros)],
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas549 ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look550 [
551 "Git operations",
552 e.gitOperations != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas553 ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look554 : "—",
555 ],
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo556 ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look557 ];
558 return (
559 <Section
560 id="plan"
561 title="Plan and entitlements"
562 description="What billing tells every service that starts compute for this workspace, now."
563 actions={
564 <span className="flex flex-wrap gap-1.5">
565 <Badge tone={e.plan === "free" ? "plain" : e.plan === "internal" ? "mint" : "lavender"}>{PLAN_LABEL[e.plan] ?? e.plan}</Badge>
566 {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
567 </span>
568 }
569 >
570 {e.paused && (
571 <div className="mb-4">
572 <Notice tone="error">Compute paused: {e.paused}</Notice>
573 </div>
574 )}
575 {(e.alerts ?? []).length > 0 && (
576 <ul className="mb-4 space-y-1 text-sm">
577 {(e.alerts ?? []).map((alert) => (
578 <li key={`${alert.meter}-${alert.level}`} className={alert.level >= 90 ? "text-warn" : "text-muted"}>
579 {alert.level}% · {alert.message || `${alert.meter}: ${usd(alert.usedMicros)} of ${usd(alert.limitMicros)}`}
580 </li>
581 ))}
582 </ul>
583 )}
584 <dl className="grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2 lg:grid-cols-3">
585 {facts.map(([label, value]) => (
586 <div key={label} className="flex justify-between gap-3 border-b border-line/60 py-1">
587 <dt className="text-faint">{label}</dt>
588 <dd className="tabular text-right text-fg-soft">{value}</dd>
589 </div>
590 ))}
591 </dl>
592 <p className="mt-3 text-xs text-faint">Agents at once, the run and issue caps, and holds are set under Plan, pools and caps below.</p>
593 </Section>
594 );
595}
596
597/** A goodwill credit from the workspace's page; Overages shows the same math for the whole queue. */
598function GoodwillForm({ overage, cap, pathname, error }: { overage: Overage | null; cap: number; pathname: string; error: SectionError }) {
599 const values = error?.values;
600 const quote = overage ? goodwillWarning(null, overage.goodwill, overage.lastGoodwillAt, cap) : null;
601 return (
602 <Section id="goodwill" title="Goodwill credit" description="For usage past what the owners meant. One click, once in 12 months; more needs a reason.">
603 {overage ? (
604 <dl className="mb-4 space-y-1 text-sm">
605 <div className="flex justify-between gap-3">
606 <dt className="text-muted">Over the typical month</dt>
607 <dd className="tabular">{usd(overage.goodwill.overageMicros)}</dd>
608 </div>
609 <div className="flex justify-between gap-3">
610 <dt className="text-muted">g1t's margin, returned</dt>
611 <dd className="tabular text-accent">{usd(overage.goodwill.marginMicros)}</dd>
612 </div>
613 <div className="flex justify-between gap-3">
614 <dt className="text-muted">Real cost absorbed</dt>
615 <dd className={`tabular ${quote?.overCap ? "text-danger" : "text-warn"}`}>{usd(overage.goodwill.absorbedMicros)}</dd>
616 </div>
617 <div className="flex justify-between gap-3 border-t border-line pt-1 font-medium">
618 <dt>One-click credit</dt>
619 <dd className="tabular">{usd(overage.goodwill.creditMicros)}</dd>
620 </div>
621 {!overage.goodwillAvailable && (
622 <p className="pt-1 text-xs text-danger">
623 A goodwill credit was given <When at={overage.lastGoodwillAt} />: another needs a reason.
624 </p>
625 )}
626 </dl>
627 ) : (
628 <p className="mb-4 text-sm text-muted">Not in the Overages queue this month: give an amount and a reason.</p>
629 )}
630 <form method="post" action={`${pathname}#goodwill`} className="space-y-3">
631 <input type="hidden" name="intent" value="goodwill" />
632 {error && <Notice tone="error">{error.error}</Notice>}
633 <div className="grid gap-3 sm:grid-cols-2">
634 <Field label="Amount $" hint={overage ? "Blank: the one-click credit." : "Required here."}>
635 <Input name="amount" inputMode="decimal" defaultValue={values?.amount ?? ""} />
636 </Field>
637 <Field label="Day of the usage" hint="Blank: billing chooses.">
638 <Input type="date" name="day" defaultValue={values?.day ?? ""} />
639 </Field>
640 </div>
641 <Field label="Reason" hint={`Needed past the one-click credit. Past ${usd(cap)} of real cost, use Overages to see it in red first.`}>
642 <Textarea name="reason" rows={2} maxLength={500} defaultValue={values?.reason ?? ""} />
643 </Field>
644 <div className="flex justify-end">
645 <Button type="submit" variant="lavender">
646 Give goodwill credit
647 </Button>
648 </div>
649 </form>
650 </Section>
651 );
652}