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

640 lines27,671 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,
13 Figure,
14 LedgerSection,
15 Owners,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look16 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's17 ResetBillingForm,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace18 ReviewPanel,
19 TermsForm,
20} from "~/components/billing";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales21import { MonthsChart } from "~/components/charts";
22import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look23import {
24 Avatar,
25 Badge,
26 Button,
27 EmptyState,
28 ExposureBar,
29 Field,
30 Input,
31 Notice,
32 Section,
33 Select,
34 StateBadge,
35 TermsBadge,
36 Textarea,
37 TrustBadge,
38 When,
39 trustAbout,
40} from "~/components/ui";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace41import { type Subject, billingAction } from "~/lib/billing-actions.server";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales42import { parseSlug, text } from "~/lib/forms";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace43import { usd } from "~/lib/money";
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales44import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
45import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look46import { goodwillWarning, spikeLabel } from "~/lib/pricing";
47import { 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 sales48import { settle } from "~/lib/settle";
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace49import { requireStaff } from "~/lib/staff";
50import type { Enterprise } from "~/lib/workspaces";
51
52export const meta: Route.MetaFunction = ({ loaderData }) => [
53 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
54 { name: "robots", content: "noindex, nofollow" },
55];
56
57/** Whether an audit line is about `slug`, in an enterprise's log. */
58function mentions(action: AdminAction, slug: string): boolean {
59 // A slug is letters, digits and hyphens, none special in a pattern.
60 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
61}
62
63async function load(raw: string) {
64 const parsed = parseSlug(raw);
65 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
66 const slug = parsed.value;
67 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
68 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
69 const detail = result.value;
70 const { account } = detail.summary;
71 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
72 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
73 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
74 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
75 return { slug, person, detail, billedTo, subject };
76}
77
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales78/** Sales and invoices are newer billing methods; a page still opens without them. */
79const SALES_DONE = new Set(["sales", "note"]);
80
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace81export async function loader({ request, params, context }: Route.LoaderArgs) {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales82 const staff = requireStaff(context);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace83 const { slug, person, detail, billedTo, subject } = await load(params.slug);
84 const { summary } = detail;
85 // On an enterprise, the limit is the enterprise's and the figures are
86 // this workspace's share of its bill.
87 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
88 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
89 const figures = billedTo
90 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
91 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look92 const [enterprises, sales, invoices, plan, overages, book] = await Promise.all([
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales93 billedTo
94 ? Promise.resolve([])
95 : admin
96 .accounts()
97 .then((rows: AccountSummary[]) =>
98 rows
99 .filter((row) => row.account.kind === "enterprise")
100 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
101 ),
102 settle(admin.sales(slug)),
103 settle(admin.workspaceInvoices(slug)),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look104 settle(entitlementsOf(slug)),
105 settle(admin.overages()),
106 settle(priceBook()),
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales107 ]);
108 const done = doneKey(request.url);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace109 return {
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales110 me: staff.email,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace111 slug,
112 name: person?.name ?? slug,
113 person,
114 billedTo,
115 terms: subject.terms,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put116 allowances: summary.account.allowances,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace117 limit,
118 figures,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales119 months: summary.months ?? [],
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace120 enterprises,
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales121 sales: sales.ok ? sales.value : null,
122 salesError: sales.ok ? null : sales.error,
123 invoices: invoices.ok ? invoices.value : [],
124 invoicesError: invoices.ok ? null : invoices.error,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look125 entitlements: plan.ok ? plan.value : null,
126 entitlementsError: plan.ok ? null : plan.error,
127 overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null,
128 forgiveCap: (book.ok && book.value.free?.overageForgiveCostMicros) || 50_000_000,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace129 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
130 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 sales131 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
132 salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace133 };
134}
135
136export async function action({ request, params, context }: Route.ActionArgs) {
137 const staff = requireStaff(context);
138 // What is acted on comes from billing and identity, not from the form.
139 const { slug, subject } = await load(params.slug);
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales140 const path = `/workspaces/${encodeURIComponent(slug)}`;
141 const form = await request.clone().formData();
142 if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
143 return billingAction(request, staff, subject, path);
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace144}
145
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales146const SECTIONS = [
147 { id: "members", label: "Members" },
148 { id: "sales", label: "Sales" },
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 { id: "plan", label: "Plan" },
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales150 { id: "billing", label: "Billing" },
151 { id: "invoices", label: "Invoices" },
152 { id: "ledger", label: "Ledger" },
153 { id: "audit", label: "Audit log" },
154];
155
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace156export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look157 const {
158 me,
159 slug,
160 name,
161 person,
162 billedTo,
163 terms,
164 allowances,
165 limit,
166 figures,
167 months,
168 enterprises,
169 sales,
170 salesError,
171 invoices,
172 invoicesError,
173 entitlements,
174 entitlementsError,
175 overage,
176 forgiveCap,
177 ledger,
178 audit,
179 done,
180 salesDone,
181 } = loaderData;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace182 const { pathname } = useLocation();
183 const result = actionData as ActionData | undefined;
184 const review = result && "review" in result ? result.review : null;
185 const link = result && "link" in result ? result.link : null;
186 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
187 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
188
189 return (
190 <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 sales191 <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 workspace192 <ArrowLeft size={14} />
193 Workspaces
194 </Link>
195
196 {/* Header */}
197 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
198 <div className="flex min-w-0 items-start gap-3">
199 <Avatar name={slug} size={40} />
200 <div className="min-w-0">
201 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
202 <p className="mt-0.5 text-xs text-faint">
203 <span className="font-mono">{slug}</span>
204 {person && (
205 <>
206 {" · "}created <When at={person.createdAt} />
207 </>
208 )}
209 </p>
210 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
211 <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 member212 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace213 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
214 <TermsBadge terms={terms} />
215 {terms.kind !== "comped" && <TrustBadge trust={limit.trust} />}
216 <StateBadge state={limit.state} />
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales217 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace218 </div>
219 </div>
220 </div>
221 {owners.length > 0 && (
222 <div className="text-xs">
223 <p className="mb-1.5 text-faint">Owners</p>
224 <Owners owners={owners} />
225 </div>
226 )}
227 </div>
228
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales229 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
230 <ul className="flex gap-1 border-b border-line text-sm">
231 {SECTIONS.map((section) => (
232 <li key={section.id}>
233 <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">
234 {section.label}
235 </a>
236 </li>
237 ))}
238 </ul>
239 </nav>
240
241 <div className="mt-6 space-y-3 empty:hidden">
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace242 {done && <Notice tone="ok">{done}</Notice>}
243 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
244 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
245 {review && <ReviewPanel review={review} pathname={pathname} />}
246 </div>
247
248 {/* People */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales249 <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 workspace250 {!person ? (
251 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
252 ) : person.members.length === 0 ? (
253 <EmptyState title="No members" />
254 ) : (
255 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
256 <table className="w-full min-w-120 text-sm">
257 <thead>
258 <tr className="border-b border-line text-left text-xs text-muted">
259 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
260 <th className="px-4 py-2 font-medium">Email</th>
261 <th className="px-4 py-2 font-medium">Role</th>
262 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
263 </tr>
264 </thead>
265 <tbody>
266 {person.members.map((member) => (
267 <tr key={member.username} className="border-b border-line last:border-0">
268 <td className="px-4 py-2.5 sm:pl-5">
269 <span className="inline-flex items-center gap-2 font-mono">
270 <Avatar name={member.username} size={18} square={false} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look271 <Link to={`/users/${member.username}`} className="hover:underline">
272 {member.username}
273 </Link>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace274 </span>
275 </td>
276 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
277 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
278 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
279 <When at={member.joined} />
280 </td>
281 </tr>
282 ))}
283 </tbody>
284 </table>
285 </div>
286 )}
287 </Section>
288
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales289 {/* Sales */}
290 <div className="mt-6">
291 <SalesSection
292 sales={sales}
293 unavailable={salesError}
294 me={me}
295 pathname={pathname}
296 salesError={error("sales")}
297 noteError={error("note")}
298 done={salesDone}
299 />
300 </div>
301
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look302 {/* Plan */}
303 <div className="mt-6">
304 <PlanSection entitlements={entitlements} unavailable={entitlementsError} />
305 </div>
306
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace307 {/* Billing */}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales308 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
309 Billing
310 </h2>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace311 <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 sales312 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace313 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
314 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
315 </div>
316
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales317 <Section
318 title="Last six months"
319 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
320 className="mt-6"
321 >
322 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
323 </Section>
324
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace325 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
326 <div className="space-y-6">
327 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
328 {billedTo ? (
329 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
330 <div className="flex flex-wrap items-center justify-between gap-3">
331 <TermsBadge terms={terms} />
332 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-merged hover:underline hover:underline-offset-4">
333 Change them on {billedTo.name}
334 </Link>
335 </div>
336 </Section>
337 ) : (
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put338 <>
339 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
340 <AllowancesForm allowances={allowances} comped={terms.kind === "comped"} pathname={pathname} error={error("allowances")} />
341 </>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace342 )}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales343 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
344 <div id="ledger" className="scroll-mt-20">
345 <LedgerSection
346 ledger={ledger}
347 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
348 />
349 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace350 </div>
351
352 <div className="space-y-6">
353 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look354 {!billedTo && terms.kind !== "comped" && (
355 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
356 )}
357 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace358 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
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's359 {!billedTo && terms.kind !== "comped" && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />}
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales360 <div id="audit" className="scroll-mt-20">
361 <AuditSection
362 audit={audit}
363 description={
364 billedTo ? (
365 <>
366 Changes about this workspace in {billedTo.name}'s log.{" "}
367 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-merged hover:underline">
368 Full log
369 </Link>
370 </>
371 ) : undefined
372 }
373 />
374 </div>
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace375 </div>
376 </div>
377 </main>
378 );
379}
380
A menu shaped by how g1t works, Soon pages for every promise, and sudo for sales381/**
382 * The limit, and in words how it is made: trust, the owners' own spend
383 * limit (or the default), the most they may set, and how it grows.
384 */
385function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
386 const lines: { label: string; value: ReactNode }[] = [];
387 if (billedTo) {
388 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
389 } else if (terms.kind !== "comped") {
390 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
391 }
392 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
393 if (!billedTo && terms.kind !== "comped") {
394 lines.push({
395 label: "Owners' limit",
396 value: limit.defaultSpendLimit ? (
397 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
398 ) : limit.spendLimitMicros == null ? (
399 "None of their own: they use everything available"
400 ) : (
401 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
402 ),
403 });
404 if (limit.availableMicros !== undefined) {
405 lines.push({
406 label: "Available",
407 value:
408 limit.availableMicros == null ? (
409 "No ceiling"
410 ) : (
411 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
412 ),
413 });
414 }
415 }
416 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
417
418 return (
419 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
420 <p className="mb-2 text-xs text-muted">
421 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
422 </p>
423 <ExposureBar limit={limit} wide />
424 {lines.length > 0 && (
425 <dl className="mt-3 space-y-1 text-xs">
426 {lines.map((line) => (
427 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
428 <dt className="text-faint">{line.label}</dt>
429 <dd className="text-fg-soft">{line.value}</dd>
430 </div>
431 ))}
432 </dl>
433 )}
434 </div>
435 );
436}
437
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace438function BilledToSection({
439 billedTo,
440 enterprises,
441 pathname,
442 error,
443}: {
444 billedTo: Enterprise | null;
445 enterprises: { id: string; name: string; members: number }[];
446 pathname: string;
447 error: SectionError;
448}) {
449 return (
450 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
451 {error && (
452 <div className="mb-4">
453 <Notice tone="error">{error.error}</Notice>
454 </div>
455 )}
456 {billedTo ? (
457 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
458 <p className="text-sm text-muted">
459 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
460 {billedTo.name}
461 </Link>{" "}
462 pays for it, under its limit and terms.
463 </p>
464 <form method="post" action={`${pathname}#review`}>
465 <input type="hidden" name="intent" value="detach" />
466 <Button type="submit" variant="danger">
467 <LogOut size={14} />
468 Move off
469 </Button>
470 </form>
471 </div>
472 ) : enterprises.length === 0 ? (
473 <p className="text-sm text-muted">
474 Itself. There are no enterprises to move it onto yet;{" "}
475 <Link to="/enterprises/new" className="text-merged hover:underline hover:underline-offset-4">
476 create one
477 </Link>
478 .
479 </p>
480 ) : (
481 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
482 <input type="hidden" name="intent" value="attach" />
483 <Field label="Itself. Move onto" className="grow">
484 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
485 <option value="" disabled>
486 Choose an enterprise
487 </option>
488 {enterprises.map((enterprise) => (
489 <option key={enterprise.id} value={enterprise.id}>
490 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
491 </option>
492 ))}
493 </Select>
494 </Field>
495 <Button type="submit" variant="quiet">
496 <Building2 size={14} />
497 Move
498 </Button>
499 </form>
500 )}
501 </Section>
502 );
503}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look504
505const PLAN_LABEL: Record<string, string> = { free: "Free", paid: "The g1t plan", internal: "Internal (comped)", enterprise: "Enterprise" };
506
507function gigabytes(bytes: number): string {
508 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
509}
510
511/** What billing tells every service about the workspace now: plan, caps, pause, trial and what the plan gives it. */
512function PlanSection({ entitlements: e, unavailable }: { entitlements: Entitlements | null; unavailable: string | null }) {
513 if (!e) {
514 return (
515 <Section id="plan" title="Plan and entitlements">
516 <Notice tone="warn">Billing did not answer for entitlements{unavailable ? `: ${unavailable}` : "."}</Notice>
517 </Section>
518 );
519 }
520 const spike = e.spike ? spikeLabel(e.spike.status) : null;
521 const facts: [string, string][] = [
522 ["Compute", e.compute ? "Yes" : e.plan === "free" && e.trialVerified && e.trialMicrosLeft > 0 ? "On the trial" : "No"],
523 ["Card check", e.trialVerified ? "Done" : "Not yet"],
524 ["Trial left", usd(e.trialMicrosLeft)],
525 ["First month", e.firstMonth ? "Yes" : "No"],
526 ["Agents at once", String(e.maxConcurrentAgents)],
527 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
528 ["Run cap", usd(e.runCapMicros)],
529 ["Issue cap", usd(e.issueCapMicros)],
530 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
531 ["Not yet paid", usd(e.exposureMicros)],
532 ["Held by reservations", usd(e.heldMicros ?? 0)],
533 ["Prepaid", usd(e.prepaidMicros ?? 0)],
534 ["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 quotas535 ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look536 ["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 quotas537 ["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 look538 [
539 "Git operations",
540 e.gitOperations != null
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas541 ? `${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 look542 : "—",
543 ],
Audit logs are kept by plan: a week on free, 90 days on the plan, and what staff set for an account in sudo544 ["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 look545 ];
546 return (
547 <Section
548 id="plan"
549 title="Plan and entitlements"
550 description="What billing tells every service that starts compute for this workspace, now."
551 actions={
552 <span className="flex flex-wrap gap-1.5">
553 <Badge tone={e.plan === "free" ? "plain" : e.plan === "internal" ? "mint" : "lavender"}>{PLAN_LABEL[e.plan] ?? e.plan}</Badge>
554 {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
555 </span>
556 }
557 >
558 {e.paused && (
559 <div className="mb-4">
560 <Notice tone="error">Compute paused: {e.paused}</Notice>
561 </div>
562 )}
563 {(e.alerts ?? []).length > 0 && (
564 <ul className="mb-4 space-y-1 text-sm">
565 {(e.alerts ?? []).map((alert) => (
566 <li key={`${alert.meter}-${alert.level}`} className={alert.level >= 90 ? "text-warn" : "text-muted"}>
567 {alert.level}% · {alert.message || `${alert.meter}: ${usd(alert.usedMicros)} of ${usd(alert.limitMicros)}`}
568 </li>
569 ))}
570 </ul>
571 )}
572 <dl className="grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2 lg:grid-cols-3">
573 {facts.map(([label, value]) => (
574 <div key={label} className="flex justify-between gap-3 border-b border-line/60 py-1">
575 <dt className="text-faint">{label}</dt>
576 <dd className="tabular text-right text-fg-soft">{value}</dd>
577 </div>
578 ))}
579 </dl>
580 <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>
581 </Section>
582 );
583}
584
585/** A goodwill credit from the workspace's page; Overages shows the same math for the whole queue. */
586function GoodwillForm({ overage, cap, pathname, error }: { overage: Overage | null; cap: number; pathname: string; error: SectionError }) {
587 const values = error?.values;
588 const quote = overage ? goodwillWarning(null, overage.goodwill, overage.lastGoodwillAt, cap) : null;
589 return (
590 <Section id="goodwill" title="Goodwill credit" description="For usage past what the owners meant. One click, once in 12 months; more needs a reason.">
591 {overage ? (
592 <dl className="mb-4 space-y-1 text-sm">
593 <div className="flex justify-between gap-3">
594 <dt className="text-muted">Over the typical month</dt>
595 <dd className="tabular">{usd(overage.goodwill.overageMicros)}</dd>
596 </div>
597 <div className="flex justify-between gap-3">
598 <dt className="text-muted">g1t's margin, returned</dt>
599 <dd className="tabular text-accent">{usd(overage.goodwill.marginMicros)}</dd>
600 </div>
601 <div className="flex justify-between gap-3">
602 <dt className="text-muted">Real cost absorbed</dt>
603 <dd className={`tabular ${quote?.overCap ? "text-danger" : "text-warn"}`}>{usd(overage.goodwill.absorbedMicros)}</dd>
604 </div>
605 <div className="flex justify-between gap-3 border-t border-line pt-1 font-medium">
606 <dt>One-click credit</dt>
607 <dd className="tabular">{usd(overage.goodwill.creditMicros)}</dd>
608 </div>
609 {!overage.goodwillAvailable && (
610 <p className="pt-1 text-xs text-danger">
611 A goodwill credit was given <When at={overage.lastGoodwillAt} />: another needs a reason.
612 </p>
613 )}
614 </dl>
615 ) : (
616 <p className="mb-4 text-sm text-muted">Not in the Overages queue this month: give an amount and a reason.</p>
617 )}
618 <form method="post" action={`${pathname}#goodwill`} className="space-y-3">
619 <input type="hidden" name="intent" value="goodwill" />
620 {error && <Notice tone="error">{error.error}</Notice>}
621 <div className="grid gap-3 sm:grid-cols-2">
622 <Field label="Amount $" hint={overage ? "Blank: the one-click credit." : "Required here."}>
623 <Input name="amount" inputMode="decimal" defaultValue={values?.amount ?? ""} />
624 </Field>
625 <Field label="Day of the usage" hint="Blank: billing chooses.">
626 <Input type="date" name="day" defaultValue={values?.day ?? ""} />
627 </Field>
628 </div>
629 <Field label="Reason" hint={`Needed past the one-click credit. Past ${usd(cap)} of real cost, use Overages to see it in red first.`}>
630 <Textarea name="reason" rows={2} maxLength={500} defaultValue={values?.reason ?? ""} />
631 </Field>
632 <div className="flex justify-end">
633 <Button type="submit" variant="lavender">
634 Give goodwill credit
635 </Button>
636 </div>
637 </form>
638 </Section>
639 );
640}