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.

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