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.

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