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.

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