Skip to content

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

677 linesCodeBlameRaw
1import { ArrowLeft, Building2, LogOut } from "lucide-react";
2import type { ReactNode } from "react";
3import { data, Link, useLocation } from "react-router";
4
5import { type AccountSummary, type AdminAction, type Entitlements, type Limit, type Overage, type Terms, httpStatus } from "@g1t/contracts";
6
7import type { Route } from "./+types/workspace";
8import {
9 AllowancesForm,
10 AuditSection,
11 BillingLinkSection,
12 CreditForm,
13 CreditsSection,
14 Figure,
15 LedgerSection,
16 Owners,
17 PaymentForm,
18 ResetBillingForm,
19 ReviewPanel,
20 TermsForm,
21} from "~/components/billing";
22import { MonthsChart } from "~/components/charts";
23import { SalesSection, StageBadge, WorkspaceInvoicesSection } from "~/components/sales";
24import { WorkspaceTestDetachSection } from "~/components/test-detach";
25import {
26 Avatar,
27 Badge,
28 Button,
29 EmptyState,
30 ExposureBar,
31 Field,
32 Input,
33 Notice,
34 Section,
35 Select,
36 StateBadge,
37 TermsBadge,
38 Textarea,
39 TrustBadge,
40 When,
41 trustAbout,
42} from "~/components/ui";
43import { type Subject, billingAction } from "~/lib/billing-actions.server";
44import { ComplimentarySection } from "~/components/complimentary";
45import { parseSlug, text } from "~/lib/forms";
46import { usd } from "~/lib/money";
47import { type ActionData, DONE, type SectionError, doneKey } from "~/lib/review";
48import { SALES_INTENTS, salesAction } from "~/lib/sales-actions.server";
49import { goodwillWarning, spikeLabel } from "~/lib/pricing";
50import { admin, entitlements as entitlementsOf, identity, priceBook } from "~/lib/services.server";
51import { settle } from "~/lib/settle";
52import { requireStaff } from "~/lib/staff";
53import { WORKSPACE_DETACH_INTENT, type WorkspaceDetachData, workspaceDetachAction } from "~/lib/test-detach.server";
54import { fullDiscount } from "~/lib/terms";
55import type { Enterprise } from "~/lib/workspaces";
56
57export const meta: Route.MetaFunction = ({ loaderData }) => [
58 { title: `${loaderData?.name ?? "Workspace"} · sudo` },
59 { name: "robots", content: "noindex, nofollow" },
60];
61
62/** Whether an audit line is about `slug`, in an enterprise's log. */
63function mentions(action: AdminAction, slug: string): boolean {
64 // A slug is letters, digits and hyphens, none special in a pattern.
65 return new RegExp(`(^|[^a-z0-9-])${slug}($|[^a-z0-9-])`).test(action.detail.toLowerCase());
66}
67
68async function load(raw: string) {
69 const parsed = parseSlug(raw);
70 if (!parsed.ok) throw data("That is not a workspace slug.", { status: 404 });
71 const slug = parsed.value;
72 const [person, result] = await Promise.all([identity.workspace(slug), admin.account(slug)]);
73 if (!result.ok) throw data(result.error.message, { status: httpStatus(result.error) });
74 const detail = result.value;
75 const { account } = detail.summary;
76 const billedTo: Enterprise | null = account.kind === "enterprise" ? { id: account.id, name: account.name } : null;
77 const known = person != null || billedTo != null || account.createdAt !== "" || detail.ledger.length > 0 || detail.audit.length > 0;
78 if (!known) throw data(`There is no workspace called ${slug}.`, { status: 404 });
79 const subject: Subject = { kind: "workspace", slug, accountId: account.id, terms: account.terms, billedTo };
80 return { slug, person, detail, billedTo, subject };
81}
82
83/** Sales and invoices are newer billing methods; a page still opens without them. */
84const SALES_DONE = new Set(["sales", "note"]);
85
86export async function loader({ request, params, context }: Route.LoaderArgs) {
87 const staff = requireStaff(context);
88 const { slug, person, detail, billedTo, subject } = await load(params.slug);
89 const { summary } = detail;
90 // On an enterprise, the limit is the enterprise's and the figures are
91 // this workspace's share of its bill.
92 const limit = (billedTo && detail.workspaces.find((member) => member.workspace === slug)) || summary.limit;
93 const share = billedTo ? summary.byWorkspace?.find((row) => row.workspace === slug) : null;
94 const figures = billedTo
95 ? { charged: share?.chargedMicros ?? 0, cost: share?.costMicros ?? 0, paid: share?.paidMicros ?? 0 }
96 : { charged: summary.chargedMicros, cost: summary.costMicros, paid: summary.paidMicros };
97 const [enterprises, sales, invoices, plan, overages, book, credits, complimentary] = await Promise.all([
98 billedTo
99 ? Promise.resolve([])
100 : admin
101 .accounts()
102 .then((rows: AccountSummary[]) =>
103 rows
104 .filter((row) => row.account.kind === "enterprise")
105 .map((row) => ({ id: row.account.id, name: row.account.name, members: row.account.workspaces.length })),
106 ),
107 settle(admin.sales(slug)),
108 settle(admin.workspaceInvoices(slug)),
109 settle(entitlementsOf(slug)),
110 settle(admin.overages()),
111 settle(priceBook()),
112 settle(admin.credits({ workspace: slug })),
113 settle(admin.complimentary(slug)),
114 ]);
115 const done = doneKey(request.url);
116 return {
117 me: staff.email,
118 slug,
119 name: person?.name ?? slug,
120 person,
121 billedTo,
122 terms: subject.terms,
123 allowances: summary.account.allowances,
124 limit,
125 figures,
126 months: summary.months ?? [],
127 enterprises,
128 sales: sales.ok ? sales.value : null,
129 salesError: sales.ok ? null : sales.error,
130 invoices: invoices.ok ? invoices.value : [],
131 invoicesError: invoices.ok ? null : invoices.error,
132 entitlements: plan.ok ? plan.value : null,
133 entitlementsError: plan.ok ? null : plan.error,
134 overage: overages.ok ? (overages.value.find((row) => row.workspace === slug) ?? null) : null,
135 forgiveCap: (book.ok && book.value.free?.overageForgiveCostMicros) || 50_000_000,
136 credits: credits.ok
137 ? { grants: credits.value.grants, leftMicros: credits.value.grants.reduce((sum, grant) => sum + grant.leftMicros, 0) }
138 : null,
139 creditsError: credits.ok ? null : credits.error,
140 complimentary: complimentary.ok ? complimentary.value : null,
141 complimentaryError: complimentary.ok ? null : complimentary.error,
142 ledger: billedTo ? detail.ledger.filter((entry) => !entry.workspace || entry.workspace === slug) : detail.ledger,
143 audit: billedTo ? detail.audit.filter((entry) => mentions(entry, slug)) : detail.audit,
144 done: done && !SALES_DONE.has(done) ? DONE[done] : null,
145 salesDone: done && SALES_DONE.has(done) ? DONE[done] : null,
146 };
147}
148
149export async function action({ request, params, context }: Route.ActionArgs) {
150 const staff = requireStaff(context);
151 // What is acted on comes from billing and identity, not from the form.
152 const { slug, subject } = await load(params.slug);
153 const path = `/workspaces/${encodeURIComponent(slug)}`;
154 const form = await request.clone().formData();
155 if (SALES_INTENTS.has(text(form, "intent"))) return salesAction(form, staff, slug, path);
156 if (text(form, "intent") === WORKSPACE_DETACH_INTENT) return workspaceDetachAction(form, staff, slug, path);
157 return billingAction(request, staff, subject, path);
158}
159
160const SECTIONS = [
161 { id: "members", label: "Members" },
162 { id: "sales", label: "Sales" },
163 { id: "plan", label: "Plan" },
164 { id: "billing", label: "Billing" },
165 { id: "credits", label: "Credits" },
166 { id: "invoices", label: "Invoices" },
167 { id: "ledger", label: "Ledger" },
168 { id: "audit", label: "Audit log" },
169];
170
171export default function Workspace({ loaderData, actionData }: Route.ComponentProps) {
172 const {
173 me,
174 slug,
175 name,
176 person,
177 billedTo,
178 terms,
179 allowances,
180 limit,
181 figures,
182 months,
183 enterprises,
184 sales,
185 salesError,
186 invoices,
187 invoicesError,
188 entitlements,
189 entitlementsError,
190 overage,
191 forgiveCap,
192 credits,
193 creditsError,
194 complimentary,
195 complimentaryError,
196 ledger,
197 audit,
198 done,
199 salesDone,
200 } = loaderData;
201 const { pathname } = useLocation();
202 const result = actionData as ActionData | undefined;
203 const review = result && "review" in result ? result.review : null;
204 const link = result && "link" in result ? result.link : null;
205 const detachPreview = actionData && typeof actionData === "object" && "testDetach" in actionData ? (actionData as WorkspaceDetachData).testDetach : null;
206 const error = (section: string): SectionError => (result && "error" in result && result.section === section ? result : null);
207 const owners = person?.members.filter((member) => member.role === "owner") ?? [];
208
209 return (
210 <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
211 <Link to="/workspaces" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
212 <ArrowLeft size={14} />
213 Workspaces
214 </Link>
215
216 {/* Header */}
217 <div className="mt-4 flex flex-wrap items-start justify-between gap-4">
218 <div className="flex min-w-0 items-start gap-3">
219 <Avatar name={slug} size={40} />
220 <div className="min-w-0">
221 <h1 className="truncate text-2xl font-semibold tracking-tight">{name}</h1>
222 <p className="mt-0.5 text-xs text-faint">
223 <span className="font-mono">{slug}</span>
224 {person && (
225 <>
226 {" · "}created <When at={person.createdAt} />
227 </>
228 )}
229 </p>
230 {person?.description && <p className="mt-1 text-sm text-muted">{person.description}</p>}
231 <div className="mt-2 flex flex-wrap gap-1.5">
232 {person?.protected && <Badge tone="info">Protected: can never be deleted</Badge>}
233 {person?.suspended && <Badge tone="danger">Suspended</Badge>}
234 <Link to={`/workspaces/${slug}/abuse`} className="self-center text-xs text-accent hover:underline">
235 Suspend and clean up
236 </Link>
237 {billedTo && <Badge tone="lavender">Billed to {billedTo.name}</Badge>}
238 <TermsBadge terms={terms} />
239 {terms.kind !== "standard" && terms.note && <span className="self-center text-xs text-muted">({terms.note})</span>}
240 {!fullDiscount(terms) && <TrustBadge trust={limit.trust} />}
241 <StateBadge state={limit.state} />
242 {sales && sales.stage !== "none" && <StageBadge stage={sales.stage} />}
243 </div>
244 </div>
245 </div>
246 {owners.length > 0 && (
247 <div className="text-xs">
248 <p className="mb-1.5 text-faint">Owners</p>
249 <Owners owners={owners} />
250 </div>
251 )}
252 </div>
253
254 <nav aria-label="On this page" className="mt-6 -mx-4 overflow-x-auto px-4">
255 <ul className="flex gap-1 border-b border-line text-sm">
256 {SECTIONS.map((section) => (
257 <li key={section.id}>
258 <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">
259 {section.label}
260 </a>
261 </li>
262 ))}
263 </ul>
264 </nav>
265
266 <div className="mt-6 space-y-3 empty:hidden">
267 {done && <Notice tone="ok">{done}</Notice>}
268 {error("top") && <Notice tone="error">{error("top")?.error}</Notice>}
269 {limit.message && <Notice tone={limit.state === "stopped" ? "error" : limit.state === "warning" ? "warn" : "info"}>{limit.message}</Notice>}
270 {review && <ReviewPanel review={review} pathname={pathname} />}
271 </div>
272
273 {/* People */}
274 <Section id="members" title="Members" description="Everyone in the workspace. Owners manage its members and billing." className="mt-6">
275 {!person ? (
276 <Notice tone="warn">Identity has no record of {slug}; only billing knows it. It may have been deleted.</Notice>
277 ) : person.members.length === 0 ? (
278 <EmptyState title="No members" />
279 ) : (
280 <div className="-mx-4 -my-4 overflow-x-auto sm:-mx-5 sm:-my-5">
281 <table className="w-full min-w-120 text-sm">
282 <thead>
283 <tr className="border-b border-line text-left text-xs text-muted">
284 <th className="px-4 py-2 font-medium sm:pl-5">Member</th>
285 <th className="px-4 py-2 font-medium">Email</th>
286 <th className="px-4 py-2 font-medium">Role</th>
287 <th className="px-4 py-2 font-medium sm:pr-5">Joined</th>
288 </tr>
289 </thead>
290 <tbody>
291 {person.members.map((member) => (
292 <tr key={member.username} className="border-b border-line last:border-0">
293 <td className="px-4 py-2.5 sm:pl-5">
294 <span className="inline-flex items-center gap-2 font-mono">
295 <Avatar name={member.username} size={18} square={false} />
296 <Link to={`/users/${member.username}`} className="hover:underline">
297 {member.username}
298 </Link>
299 </span>
300 </td>
301 <td className="px-4 py-2.5 break-all text-muted">{member.email ?? <span className="text-faint">—</span>}</td>
302 <td className="px-4 py-2.5">{member.role === "owner" ? <Badge tone="lavender">Owner</Badge> : <Badge>Member</Badge>}</td>
303 <td className="px-4 py-2.5 text-xs whitespace-nowrap text-muted sm:pr-5">
304 <When at={member.joined} />
305 </td>
306 </tr>
307 ))}
308 </tbody>
309 </table>
310 </div>
311 )}
312 </Section>
313
314 {/* Sales */}
315 <div className="mt-6">
316 <SalesSection
317 sales={sales}
318 unavailable={salesError}
319 me={me}
320 pathname={pathname}
321 salesError={error("sales")}
322 noteError={error("note")}
323 done={salesDone}
324 />
325 </div>
326
327 {/* Plan */}
328 <div className="mt-6">
329 <PlanSection entitlements={entitlements} unavailable={entitlementsError} />
330 </div>
331
332 {/* Billing */}
333 <h2 id="billing" className="mt-10 scroll-mt-20 text-lg font-semibold tracking-tight">
334 Billing
335 </h2>
336 <div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
337 <LimitCard limit={limit} terms={terms} billedTo={billedTo} />
338 <Figure label="Charged this month" value={usd(figures.charged)} hint={`Cost to g1t ${usd(figures.cost)}`} />
339 <Figure label="Paid ever" value={usd(figures.paid)} hint={`Margin this month ${usd(figures.charged - figures.cost)}`} />
340 </div>
341
342 <Section
343 title="Last six months"
344 description={billedTo ? `${billedTo.name}'s figures: every workspace it pays for, together.` : "What it was charged, against what its usage cost g1t."}
345 className="mt-6"
346 >
347 <MonthsChart wide months={months} label={`${billedTo ? billedTo.name : name}: charged and cost by month`} />
348 </Section>
349
350 <div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
351 <div className="space-y-6">
352 <BilledToSection billedTo={billedTo} enterprises={enterprises} pathname={pathname} error={error("billed-to")} />
353 {billedTo ? (
354 <Section id="terms" title="Terms" description={`Charged on ${billedTo.name}'s terms while it pays for this workspace.`}>
355 <div className="flex flex-wrap items-center justify-between gap-3">
356 <TermsBadge terms={terms} />
357 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}#terms`} className="text-sm text-accent hover:underline hover:underline-offset-4">
358 Change them on {billedTo.name}
359 </Link>
360 </div>
361 </Section>
362 ) : (
363 <>
364 <ComplimentarySection
365 workspace={slug}
366 standing={complimentary}
367 unavailable={complimentaryError}
368 pathname={pathname}
369 error={error("complimentary")}
370 />
371 <TermsForm terms={terms} pathname={pathname} error={error("terms")} />
372 <AllowancesForm allowances={allowances} comped={fullDiscount(terms)} pathname={pathname} error={error("allowances")} />
373 </>
374 )}
375 <CreditsSection credits={credits} unavailable={creditsError} pathname={pathname} error={error("credits")} />
376 <WorkspaceInvoicesSection invoices={invoices} unavailable={invoicesError} />
377 <div id="ledger" className="scroll-mt-20">
378 <LedgerSection
379 ledger={ledger}
380 description={billedTo ? `This workspace's lines among ${billedTo.name}'s latest 100.` : "Recent lines of the statement, newest first."}
381 />
382 </div>
383 </div>
384
385 <div className="space-y-6">
386 <BillingLinkSection link={link} pathname={pathname} error={error("billing-link")} />
387 {!billedTo && !fullDiscount(terms) && (
388 <GoodwillForm overage={overage} cap={forgiveCap} pathname={pathname} error={error("goodwill")} />
389 )}
390 <PaymentForm workspace={slug} pathname={pathname} error={error("payment")} />
391 <CreditForm workspaces={[slug]} pathname={pathname} error={error("credit")} />
392 {!billedTo && !fullDiscount(terms) && <ResetBillingForm workspace={slug} pathname={pathname} error={error("reset")} />}
393 {!billedTo && <WorkspaceTestDetachSection workspace={slug} pathname={pathname} preview={detachPreview} error={error("test-detach")} />}
394 <div id="audit" className="scroll-mt-20">
395 <AuditSection
396 audit={audit}
397 description={
398 billedTo ? (
399 <>
400 Changes about this workspace in {billedTo.name}'s log.{" "}
401 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="text-accent hover:underline">
402 Full log
403 </Link>
404 </>
405 ) : undefined
406 }
407 />
408 </div>
409 </div>
410 </div>
411 </main>
412 );
413}
414
415/**
416 * The limit, and in words how it is made: trust, the owners' own spend
417 * limit (or the default), the most they may set, and how it grows.
418 */
419function LimitCard({ limit, terms, billedTo }: { limit: Limit; terms: Terms; billedTo: Enterprise | null }) {
420 const lines: { label: string; value: ReactNode }[] = [];
421 if (billedTo) {
422 lines.push({ label: "Limit", value: <>{billedTo.name}'s, shared by every workspace it pays for</> });
423 } else if (!fullDiscount(terms)) {
424 lines.push({ label: "From trust", value: <>{usd(limit.trustCeilingMicros)}. {trustAbout(limit.trust)}</> });
425 }
426 if (terms.ceilingMicros != null) lines.push({ label: "Custom limit", value: usd(terms.ceilingMicros) });
427 if (!billedTo && !fullDiscount(terms)) {
428 lines.push({
429 label: "Owners' limit",
430 value: limit.defaultSpendLimit ? (
431 <>The default, {usd(limit.spendLimitMicros ?? 200_000_000)} a month: they have not chosen one</>
432 ) : limit.spendLimitMicros == null ? (
433 "None of their own: they use everything available"
434 ) : (
435 <>{usd(limit.spendLimitMicros)} a month, chosen by the owners</>
436 ),
437 });
438 if (limit.availableMicros !== undefined) {
439 lines.push({
440 label: "Available",
441 value:
442 limit.availableMicros == null ? (
443 "No ceiling"
444 ) : (
445 <>Up to {usd(limit.availableMicros)}: the most owners may set; past it, they contact g1t</>
446 ),
447 });
448 }
449 }
450 if (limit.growth) lines.push({ label: "Grows", value: limit.growth });
451
452 return (
453 <div className="rounded-lg border border-line bg-surface px-4 py-3 sm:col-span-2">
454 <p className="mb-2 text-xs text-muted">
455 Usage this month against the limit{billedTo && <> · shared with every workspace {billedTo.name} pays for</>}
456 </p>
457 <ExposureBar limit={limit} wide />
458 {lines.length > 0 && (
459 <dl className="mt-3 space-y-1 text-xs">
460 {lines.map((line) => (
461 <div key={line.label} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-2">
462 <dt className="text-faint">{line.label}</dt>
463 <dd className="text-fg-soft">{line.value}</dd>
464 </div>
465 ))}
466 </dl>
467 )}
468 </div>
469 );
470}
471
472function BilledToSection({
473 billedTo,
474 enterprises,
475 pathname,
476 error,
477}: {
478 billedTo: Enterprise | null;
479 enterprises: { id: string; name: string; members: number }[];
480 pathname: string;
481 error: SectionError;
482}) {
483 return (
484 <Section id="billed-to" title="Billed to" description="Whether the workspace pays for itself, or an enterprise pays for it.">
485 {error && (
486 <div className="mb-4">
487 <Notice tone="error">{error.error}</Notice>
488 </div>
489 )}
490 {billedTo ? (
491 <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
492 <p className="text-sm text-muted">
493 <Link to={`/enterprises/${encodeURIComponent(billedTo.id)}`} className="font-medium text-fg hover:underline hover:underline-offset-4">
494 {billedTo.name}
495 </Link>{" "}
496 pays for it, under its limit and terms.
497 </p>
498 <form method="post" action={`${pathname}#review`}>
499 <input type="hidden" name="intent" value="detach" />
500 <Button type="submit" variant="danger">
501 <LogOut size={14} />
502 Move off
503 </Button>
504 </form>
505 </div>
506 ) : enterprises.length === 0 ? (
507 <p className="text-sm text-muted">
508 Itself. There are no enterprises to move it onto yet;{" "}
509 <Link to="/enterprises/new" className="text-accent hover:underline hover:underline-offset-4">
510 create one
511 </Link>
512 .
513 </p>
514 ) : (
515 <form method="post" action={`${pathname}#review`} className="flex flex-col gap-2 sm:flex-row sm:items-end">
516 <input type="hidden" name="intent" value="attach" />
517 <Field label="Itself. Move onto" className="grow">
518 <Select name="target" required defaultValue={error?.values?.target ?? ""}>
519 <option value="" disabled>
520 Choose an enterprise
521 </option>
522 {enterprises.map((enterprise) => (
523 <option key={enterprise.id} value={enterprise.id}>
524 {enterprise.name} ({enterprise.members} workspace{enterprise.members === 1 ? "" : "s"})
525 </option>
526 ))}
527 </Select>
528 </Field>
529 <Button type="submit" variant="quiet">
530 <Building2 size={14} />
531 Move
532 </Button>
533 </form>
534 )}
535 </Section>
536 );
537}
538
539const PLAN_LABEL: Record<string, string> = {
540 free: "Free",
541 paid: "The g1t plan",
542 internal: "Internal (100% discount)",
543 enterprise: "Enterprise",
544 complimentary: "Complimentary",
545};
546
547function gigabytes(bytes: number): string {
548 return `${Math.round((bytes / 1e9) * 100) / 100} GB`;
549}
550
551/** What billing tells every service about the workspace now: plan, caps, pause, trial and what the plan gives it. */
552function PlanSection({ entitlements: e, unavailable }: { entitlements: Entitlements | null; unavailable: string | null }) {
553 if (!e) {
554 return (
555 <Section id="plan" title="Plan and entitlements">
556 <Notice tone="warn">Billing did not answer for entitlements{unavailable ? `: ${unavailable}` : "."}</Notice>
557 </Section>
558 );
559 }
560 const spike = e.spike ? spikeLabel(e.spike.status) : null;
561 const facts: [string, string][] = [
562 ["Compute", e.compute ? "Yes" : e.plan === "free" && e.trialVerified && e.trialMicrosLeft > 0 ? "On the trial" : "No"],
563 ["Card check", e.trialVerified ? "Done" : "Not yet"],
564 ["Trial left", usd(e.trialMicrosLeft)],
565 ["First month", e.firstMonth ? "Yes" : "No"],
566 ["Longest run", e.maxRunMinutes ? `${e.maxRunMinutes} min` : "The guardrails'"],
567 ["Ceiling", e.ceilingMicros >= 1e15 ? "None" : usd(e.ceilingMicros)],
568 ["Not yet paid", usd(e.exposureMicros)],
569 ["Held by reservations", usd(e.heldMicros ?? 0)],
570 ["Prepaid", usd(e.prepaidMicros ?? 0)],
571 ["Included usage", e.includedMicros ? `${usd(e.includedUsedMicros ?? 0)} of ${usd(e.includedMicros)}` : "—"],
572 ["Private storage", `${gigabytes(e.privateStorageBytes)} (${gigabytes(e.freePrivateStorageBytes)} free)`],
573 ["Open-source pool paid", usd(e.ossPaidMicros)],
574 ["Build minutes", `${Math.ceil((e.buildSecondsUsed ?? 0) / 60)}, all metered`],
575 [
576 "Git operations",
577 e.gitOperations != null
578 ? `${e.gitOperations.toLocaleString("en-US")}${e.gitOperationsIncluded ? ` (${e.gitOperationsIncluded.toLocaleString("en-US")} free)` : ""}`
579 : "—",
580 ],
581 ["Audit log", `${e.auditRetentionDays} days${e.auditRetentionCustom ? ", set by staff" : ""}`],
582 ];
583 return (
584 <Section
585 id="plan"
586 title="Plan and entitlements"
587 description="What billing tells every service that starts compute for this workspace, now."
588 actions={
589 <span className="flex flex-wrap gap-1.5">
590 <Badge tone={e.plan === "free" ? "plain" : e.plan === "internal" ? "mint" : "lavender"}>{PLAN_LABEL[e.plan] ?? e.plan}</Badge>
591 {spike && <Badge tone={spike.tone === "plain" ? "plain" : spike.tone}>{spike.label}</Badge>}
592 </span>
593 }
594 >
595 {e.paused && (
596 <div className="mb-4">
597 <Notice tone="error">Compute paused: {e.paused}</Notice>
598 </div>
599 )}
600 {(e.alerts ?? []).length > 0 && (
601 <ul className="mb-4 space-y-1 text-sm">
602 {(e.alerts ?? []).map((alert) => (
603 <li key={`${alert.meter}-${alert.level}`} className={alert.level >= 90 ? "text-warn" : "text-muted"}>
604 {alert.level}% · {alert.message || `${alert.meter}: ${usd(alert.usedMicros)} of ${usd(alert.limitMicros)}`}
605 </li>
606 ))}
607 </ul>
608 )}
609 <dl className="grid gap-x-6 gap-y-1 text-xs sm:grid-cols-2 lg:grid-cols-3">
610 {facts.map(([label, value]) => (
611 <div key={label} className="flex justify-between gap-3 border-b border-line/60 py-1">
612 <dt className="text-faint">{label}</dt>
613 <dd className="tabular text-right text-fg-soft">{value}</dd>
614 </div>
615 ))}
616 </dl>
617 <p className="mt-3 text-xs text-faint">Holds are set under Plan, pools and caps below.</p>
618 </Section>
619 );
620}
621
622/** A goodwill credit from the workspace's page; Overages shows the same math for the whole queue. */
623function GoodwillForm({ overage, cap, pathname, error }: { overage: Overage | null; cap: number; pathname: string; error: SectionError }) {
624 const values = error?.values;
625 const quote = overage ? goodwillWarning(null, overage.goodwill, overage.lastGoodwillAt, cap) : null;
626 return (
627 <Section id="goodwill" title="Goodwill credit" description="For usage past what the owners meant. One click, once in 12 months; more needs a reason.">
628 {overage ? (
629 <dl className="mb-4 space-y-1 text-sm">
630 <div className="flex justify-between gap-3">
631 <dt className="text-muted">Over the typical month</dt>
632 <dd className="tabular">{usd(overage.goodwill.overageMicros)}</dd>
633 </div>
634 <div className="flex justify-between gap-3">
635 <dt className="text-muted">g1t's margin, returned</dt>
636 <dd className="tabular text-success">{usd(overage.goodwill.marginMicros)}</dd>
637 </div>
638 <div className="flex justify-between gap-3">
639 <dt className="text-muted">Real cost absorbed</dt>
640 <dd className={`tabular ${quote?.overCap ? "text-danger" : "text-warn"}`}>{usd(overage.goodwill.absorbedMicros)}</dd>
641 </div>
642 <div className="flex justify-between gap-3 border-t border-line pt-1 font-medium">
643 <dt>One-click credit</dt>
644 <dd className="tabular">{usd(overage.goodwill.creditMicros)}</dd>
645 </div>
646 {!overage.goodwillAvailable && (
647 <p className="pt-1 text-xs text-danger">
648 A goodwill credit was given <When at={overage.lastGoodwillAt} />: another needs a reason.
649 </p>
650 )}
651 </dl>
652 ) : (
653 <p className="mb-4 text-sm text-muted">Not in the Overages queue this month: give an amount and a reason.</p>
654 )}
655 <form method="post" action={`${pathname}#goodwill`} className="space-y-3">
656 <input type="hidden" name="intent" value="goodwill" />
657 {error && <Notice tone="error">{error.error}</Notice>}
658 <div className="grid gap-3 sm:grid-cols-2">
659 <Field label="Amount $" hint={overage ? "Blank: the one-click credit." : "Required here."}>
660 <Input name="amount" inputMode="decimal" defaultValue={values?.amount ?? ""} />
661 </Field>
662 <Field label="Day of the usage" hint="Blank: billing chooses.">
663 <Input type="date" name="day" defaultValue={values?.day ?? ""} />
664 </Field>
665 </div>
666 <Field label="Reason" hint={`Needed past the one-click credit. Past ${usd(cap)} of real cost, use Overages to see it in red first.`}>
667 <Textarea name="reason" rows={2} maxLength={500} defaultValue={values?.reason ?? ""} />
668 </Field>
669 <div className="flex justify-end">
670 <Button type="submit" variant="lavender">
671 Give goodwill credit
672 </Button>
673 </div>
674 </form>
675 </Section>
676 );
677}