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

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