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

782 lines38,182 bytesCodeBlame
1import type { ReactNode } from "react";
2import { Link, data, redirect } from "react-router";
3
4import type { CostsReport, PriceProposal } from "@g1t/contracts";
5
6import type { Route } from "./+types/costs";
7import { DaysChart } from "~/components/costs";
8import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Stat, When } from "~/components/ui";
9import {
10 capPercent,
11 countLabel,
12 daySeries,
13 driftLabel,
14 marginTone,
15 parseBucket,
16 parseCostSettings,
17 parseMapping,
18 parseRange,
19 percentLabel,
20 spendRows,
21 unitDollars,
22} from "~/lib/costs";
23import { dollarsField, usd } from "~/lib/money";
24import { admin } from "~/lib/services.server";
25import { settle } from "~/lib/settle";
26import { requireStaff } from "~/lib/staff";
27
28export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }];
29
30const DONE: Record<string, string> = {
31 run: "Read Cloudflare's bill and reconciled. The figures below are fresh.",
32 approved: "Approved. A fall applies now; a rise after the notice period, and owners on the plan are emailed.",
33 rejected: "Rejected, with the note kept for whoever measures it next.",
34 settings: "Guardrails saved. They apply from the next run.",
35 mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
36 removed: "Mapping removed.",
37 lifted: "Breaker lifted for the rest of today (UTC). Hosted-model runs start again; it is recorded in the audit log.",
38};
39
40const RANGES = [7, 30, 90];
41
42export async function loader({ request, context }: Route.LoaderArgs) {
43 requireStaff(context);
44 const url = new URL(request.url);
45 const range = parseRange(url.searchParams.get("days"));
46 const report = await settle(admin.costs(range));
47 const done = url.searchParams.get("done");
48 return {
49 range,
50 bucket: url.searchParams.get("product"),
51 report: report.ok ? report.value : null,
52 error: report.ok ? null : report.error,
53 done: done ? (DONE[done] ?? null) : null,
54 };
55}
56
57type ActionResult = { error: string; section: string; values?: Record<string, string> };
58
59export async function action({ request, context }: Route.ActionArgs) {
60 const staff = requireStaff(context);
61 const form = await request.formData();
62 const intent = String(form.get("intent") ?? "");
63 const fail = (section: string, error: string) =>
64 data<ActionResult>({ error, section, values: Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])) }, { status: 422 });
65 const back = (done: string, hash = "") => {
66 const url = new URL(request.url);
67 url.searchParams.set("done", done);
68 return redirect(`${url.pathname}${url.search}${hash}`);
69 };
70 if (intent === "run") {
71 const run = await settle(admin.runCosts(staff.email));
72 if (!run.ok) return fail("run", `Billing did not answer: ${run.error}`);
73 if (!run.value.ok) return fail("run", run.value.error.message);
74 if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" "));
75 throw back("run");
76 }
77 if (intent === "lift") {
78 const note = String(form.get("note") ?? "").trim().slice(0, 500);
79 if (note.length < 5) return fail("lift", "Say why it is lifted, for whoever looks next.");
80 const result = await settle(admin.liftBreaker(note, staff.email));
81 if (!result.ok) return fail("lift", `Billing did not answer: ${result.error}`);
82 if (!result.value.ok) return fail("lift", result.value.error.message);
83 throw back("lifted", "#spend");
84 }
85 if (intent === "decide") {
86 const id = String(form.get("id") ?? "");
87 const decision = form.get("decision") === "approve" ? "approve" : "reject";
88 const note = String(form.get("note") ?? "").trim().slice(0, 500);
89 if (decision === "reject" && note.length < 5) return fail(`proposal-${id}`, "Say why it is rejected, for whoever measures it next.");
90 const result = await admin.decideProposal(id, decision, note, staff.email);
91 if (!result.ok) return fail(`proposal-${id}`, result.error.message);
92 throw back(decision === "approve" ? "approved" : "rejected", "#proposals");
93 }
94 if (intent === "settings") {
95 const parsed = parseCostSettings(form);
96 if (!parsed.ok) return fail("settings", parsed.error);
97 const result = await admin.setCostSettings(parsed.value, staff.email);
98 if (!result.ok) return fail("settings", result.error.message);
99 throw back("settings", "#guardrails");
100 }
101 if (intent === "mapping") {
102 const parsed = parseMapping(form);
103 if (!parsed.ok) return fail("mapping", parsed.error);
104 const result = await admin.setCostMapping(parsed.value, staff.email);
105 if (!result.ok) return fail("mapping", result.error.message);
106 throw back(parsed.value.remove ? "removed" : "mapping", "#mappings");
107 }
108 return fail("run", "Unknown change.");
109}
110
111function chip(active: boolean) {
112 return `inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
113 active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
114 }`;
115}
116
117function href(range: number, bucket: string | null) {
118 const params = new URLSearchParams();
119 if (range !== 30) params.set("days", String(range));
120 if (bucket) params.set("product", bucket);
121 const query = params.toString();
122 return query ? `/costs?${query}` : "/costs";
123}
124
125export default function Costs({ loaderData, actionData }: Route.ComponentProps) {
126 const { range, report, error, done } = loaderData;
127 const failed = actionData && "error" in actionData ? (actionData as ActionResult) : null;
128 if (!report) {
129 return (
130 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
131 <PageHeader title="Costs & margin" />
132 <div className="mt-5">
133 <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
134 </div>
135 </main>
136 );
137 }
138 const bucket = parseBucket(loaderData.bucket, report.products.map((p) => p.bucket));
139 const product = report.products.find((p) => p.bucket === bucket) ?? null;
140 const series = daySeries(report.days, report.since, report.until, bucket);
141 const floor = report.settings.marginFloorPercent;
142 const open = report.proposals.filter((p) => p.status === "open");
143 // Margin under the floor at the top; drift and leaks are in their own table, workspaces on Reach out.
144 const banner = report.alerts.filter((a) => a.kind === "overall" || a.kind === "margin");
145 const elsewhere = report.alerts.length - banner.length;
146 return (
147 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
148 <PageHeader
149 title="Costs & margin"
150 description="What Cloudflare charged g1t, day by day, against what g1t charged for the same things. Prices are cost plus 20%; this is where that is checked against the bill, where drift and leaks show, and where price changes wait for a decision."
151 actions={
152 <form method="post">
153 <input type="hidden" name="intent" value="run" />
154 <Button variant="quiet" type="submit">
155 Read the bill now
156 </Button>
157 </form>
158 }
159 />
160 <nav aria-label="Range" className="mt-5 flex flex-wrap gap-2">
161 {RANGES.map((days) => (
162 <Link key={days} to={href(days, bucket)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}>
163 Last {days} days
164 </Link>
165 ))}
166 </nav>
167 <div className="mt-5 space-y-3 empty:hidden">
168 {done && <Notice tone="ok">{done}</Notice>}
169 {failed?.section === "run" && <Notice tone="error">{failed.error}</Notice>}
170 {!report.configured && (
171 <Notice tone="warn">
172 Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account:
173 Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing.
174 </Notice>
175 )}
176 {banner.map((alert) => (
177 <Notice key={alert.id} tone="error">
178 {alert.detail} <span className="text-faint">Since {alert.since}.</span>
179 </Notice>
180 ))}
181 {elsewhere > 0 && (
182 <p className="text-xs text-muted">
183 {elsewhere} more open: {" "}
184 <a href="#drift" className="underline underline-offset-2">
185 drift and leaks
186 </a>{" "}
187 below, and workspaces that cost more than they pay on{" "}
188 <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2">
189 Reach out
190 </Link>
191 .
192 </p>
193 )}
194 </div>
195
196 <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
197 <Stat
198 label="Money in"
199 value={usd(report.overall.usageMicros + report.overall.plansMicros)}
200 hint={`${usd(report.overall.usageMicros)} usage, ${usd(report.overall.plansMicros)} plans`}
201 />
202 <Stat label="Cloudflare cost" value={usd(report.overall.costMicros)} hint={`${report.since} to ${report.until}`} />
203 <Stat
204 label="Margin"
205 value={percentLabel(report.overall.marginPercent)}
206 hint={usd(report.overall.marginMicros)}
207 tone={marginTone(report.overall.marginPercent, floor)}
208 />
209 <Stat
210 label="Proposals waiting"
211 value={String(open.length)}
212 hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"}
213 tone={open.length > 0 ? "warn" : undefined}
214 />
215 </div>
216
217 <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} />
218
219 <Section
220 className="mt-6"
221 title={product ? `${product.title}, by day` : "By day"}
222 description={
223 product
224 ? "What customers were charged for it at price, before included usage and pools paid part, against what it cost."
225 : "Money in (usage paid for and the plan) against every cost. Pick a product to see its own."
226 }
227 >
228 <nav aria-label="Product" className="mb-4 flex flex-wrap gap-2">
229 <Link to={href(range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}>
230 All of g1t
231 </Link>
232 {report.products.map((p) => (
233 <Link key={p.bucket} to={href(range, p.bucket)} aria-current={p.bucket === bucket ? "page" : undefined} className={chip(p.bucket === bucket)}>
234 {p.title}
235 </Link>
236 ))}
237 </nav>
238 <DaysChart
239 days={series}
240 floor={floor}
241 revenueLabel={product ? "Charged at price" : "Money in"}
242 label={product ? `${product.title}: charged against Cloudflare cost by day` : "Money in against Cloudflare cost by day"}
243 />
244 </Section>
245
246 <Section className="mt-6" title="By product" description={`Each of g1t's products over the last ${range} days. The floor is ${floor}%.`}>
247 <div className="-mx-4 overflow-x-auto sm:-mx-5">
248 <table className="w-full min-w-[44rem] text-sm">
249 <thead>
250 <tr className="border-b border-line text-left text-xs text-muted">
251 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
252 <th className="px-4 py-2 text-right font-medium">Charged at price</th>
253 <th className="px-4 py-2 text-right font-medium">Cost</th>
254 <th className="px-4 py-2 text-right font-medium">Price book said</th>
255 <th className="px-4 py-2 text-right font-medium">Margin</th>
256 <th className="px-4 py-2 text-right font-medium sm:pr-5">%</th>
257 </tr>
258 </thead>
259 <tbody>
260 {report.products.map((p) => {
261 const tone = p.overhead ? undefined : marginTone(p.marginPercent, floor);
262 return (
263 <tr key={p.bucket} className="border-b border-line last:border-0">
264 <td className="px-4 py-2.5 sm:px-5">
265 <Link to={href(range, p.bucket)} className="text-fg hover:underline">
266 {p.title}
267 </Link>
268 <span className="block text-xs text-faint">
269 {p.overhead ? "Paid by the plan" : p.costSource === "ledger" ? "Cost from the gateway, not Cloudflare" : p.bucket}
270 </span>
271 </td>
272 <td className="tabular px-4 py-2.5 text-right">{usd(p.valueMicros)}</td>
273 <td className="tabular px-4 py-2.5 text-right">{usd(p.costMicros)}</td>
274 <td className="tabular px-4 py-2.5 text-right text-muted">{usd(p.ownCostMicros)}</td>
275 <td className={`tabular px-4 py-2.5 text-right ${p.marginMicros < 0 && !p.overhead ? "text-danger" : ""}`}>{usd(p.marginMicros)}</td>
276 <td className="px-4 py-2.5 text-right sm:pr-5">
277 {p.overhead ? (
278 <span className="text-faint">—</span>
279 ) : (
280 <Badge tone={tone === "mint" ? "mint" : tone === "warn" ? "warn" : tone === "danger" ? "danger" : "plain"}>{percentLabel(p.marginPercent)}</Badge>
281 )}
282 </td>
283 </tr>
284 );
285 })}
286 </tbody>
287 </table>
288 </div>
289 </Section>
290
291 <Section
292 className="mt-6"
293 id="drift"
294 title="Drift"
295 description="Over the last 7 days: counts g1t and Cloudflare disagree on past a mapping's threshold, a bill far from the price book's cost of the same usage, and leaks (cost nothing charges for, or a Cloudflare meter no one mapped)."
296 >
297 {report.drift.length === 0 ? (
298 <p className="text-sm text-muted">Nothing has drifted.</p>
299 ) : (
300 <div className="-mx-4 overflow-x-auto sm:-mx-5">
301 <table className="w-full min-w-[44rem] text-sm">
302 <thead>
303 <tr className="border-b border-line text-left text-xs text-muted">
304 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
305 <th className="px-4 py-2 font-medium">Kind</th>
306 <th className="px-4 py-2 text-right font-medium">g1t</th>
307 <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
308 <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
309 </tr>
310 </thead>
311 <tbody>
312 {report.drift.map((d) => {
313 const money = d.kind !== "count";
314 return (
315 <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
316 <td className="px-4 py-2.5 sm:px-5">
317 {d.title}
318 <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
319 </td>
320 <td className="px-4 py-2.5">
321 <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
322 </td>
323 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
324 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
325 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
326 </tr>
327 );
328 })}
329 </tbody>
330 </table>
331 </div>
332 )}
333 </Section>
334
335 <Section
336 className="mt-6"
337 id="proposals"
338 title="Price proposals"
339 description={`What the keeper and the reconciler measured. Moves within ${report.settings.autoApplyPercent}% apply themselves${report.settings.autoApply ? "" : " (off now)"}; larger ones wait here. A fall applies at once; a rise after ${report.settings.noticeDays} days' notice, and a monthly meter's from the start of the next month.`}
340 >
341 {report.proposals.length === 0 ? (
342 <p className="text-sm text-muted">No proposals yet.</p>
343 ) : (
344 <ul className="space-y-3">
345 {report.proposals.map((p) => (
346 <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
347 ))}
348 </ul>
349 )}
350 </Section>
351
352 <Section className="mt-6" title="Price versions" description="Every price as it was and will be. A version never changes once written; each charge records the version it was made at.">
353 <div className="-mx-4 overflow-x-auto sm:-mx-5">
354 <table className="w-full min-w-[44rem] text-sm">
355 <thead>
356 <tr className="border-b border-line text-left text-xs text-muted">
357 <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
358 <th className="px-4 py-2 text-right font-medium">Cost</th>
359 <th className="px-4 py-2 text-right font-medium">Price</th>
360 <th className="px-4 py-2 font-medium">From</th>
361 <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
362 </tr>
363 </thead>
364 <tbody>
365 {report.versions.map((v) => (
366 <tr key={v.id} className="border-b border-line align-top last:border-0">
367 <td className="px-4 py-2.5 sm:px-5">
368 <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
369 {!v.appliedAt && (
370 <span className="ml-2">
371 <Badge tone="info">Coming</Badge>
372 </span>
373 )}
374 </td>
375 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
376 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
377 <td className="px-4 py-2.5 text-xs text-muted">
378 <When at={v.effectiveAt} />
379 </td>
380 <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
381 {v.reason} · {v.createdBy}
382 </td>
383 </tr>
384 ))}
385 </tbody>
386 </table>
387 </div>
388 </Section>
389
390 <Section
391 className="mt-6"
392 title="Workspaces that cost most"
393 description="Cloudflare's costs shared out by each workspace's own meters (its git operations, its usage), against what it paid. Over its revenue for 30 days, a workspace shows on Reach out."
394 >
395 {report.topWorkspaces.length === 0 ? (
396 <p className="text-sm text-muted">No costs shared out yet.</p>
397 ) : (
398 <div className="-mx-4 overflow-x-auto sm:-mx-5">
399 <table className="w-full min-w-[34rem] text-sm">
400 <thead>
401 <tr className="border-b border-line text-left text-xs text-muted">
402 <th className="px-4 py-2 font-medium sm:px-5">Workspace</th>
403 <th className="px-4 py-2 text-right font-medium">Cost to g1t</th>
404 <th className="px-4 py-2 text-right font-medium">Paid</th>
405 <th className="px-4 py-2 text-right font-medium sm:pr-5">Net</th>
406 </tr>
407 </thead>
408 <tbody>
409 {report.topWorkspaces.map((w) => {
410 const net = w.revenueMicros - w.costMicros;
411 return (
412 <tr key={w.workspace} className="border-b border-line last:border-0">
413 <td className="px-4 py-2.5 sm:px-5">
414 <Link to={`/workspaces/${encodeURIComponent(w.workspace)}#billing`} className="font-mono text-fg hover:underline">
415 {w.workspace}
416 </Link>
417 {w.internal && <span className="ml-2 text-xs text-faint">g1t's own</span>}
418 </td>
419 <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td>
420 <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td>
421 <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${net < 0 && !w.internal ? "text-danger" : "text-fg-soft"}`}>{usd(net)}</td>
422 </tr>
423 );
424 })}
425 </tbody>
426 </table>
427 </div>
428 )}
429 </Section>
430
431 <Section
432 className="mt-6"
433 title="Cloudflare's lines"
434 description="Every meter Cloudflare billed or counted in the range, as it named it, and which of g1t's products it is a cost of. Not mapped means no one decided what pays for it."
435 >
436 {report.lines.length === 0 ? (
437 <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Read the bill now”.</EmptyState>
438 ) : (
439 <div className="-mx-4 overflow-x-auto sm:-mx-5">
440 <table className="w-full min-w-[48rem] text-sm">
441 <thead>
442 <tr className="border-b border-line text-left text-xs text-muted">
443 <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
444 <th className="px-4 py-2 text-right font-medium">Quantity</th>
445 <th className="px-4 py-2 text-right font-medium">Cost</th>
446 <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
447 </tr>
448 </thead>
449 <tbody>
450 {report.lines.map((l) => (
451 <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
452 <td className="px-4 py-2.5 sm:px-5">
453 <span className="font-mono text-xs">
454 {l.product} / {l.meter}
455 </span>
456 <span className="block text-xs text-faint">
457 {l.rawName}
458 {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
459 </span>
460 </td>
461 <td className="tabular px-4 py-2.5 text-right">
462 {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
463 </td>
464 <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
465 <td className="px-4 py-2.5 sm:pr-5">{l.bucket ? <span className="text-fg-soft">{l.bucket}</span> : <Badge tone="danger">Not mapped</Badge>}</td>
466 </tr>
467 ))}
468 </tbody>
469 </table>
470 </div>
471 )}
472 </Section>
473
474 <Section
475 className="mt-6"
476 id="mappings"
477 title="Mappings"
478 description="Which of g1t's products each Cloudflare meter is a cost of. The longest matching meter prefix wins; * is the rest of the product. A price meter is measured from it; an own meter is g1t's count to compare; scaling prices one of g1t's units at as many of Cloudflare's as it took."
479 >
480 <MappingsTable report={report} />
481 <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
482 <input type="hidden" name="intent" value="mapping" />
483 <Field label="Cloudflare product">
484 <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
485 </Field>
486 <Field label="Meter prefix" hint="Or * for all of it">
487 <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
488 </Field>
489 <Field label="g1t product">
490 <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
491 </Field>
492 <Field label="Drift threshold, %">
493 <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
494 </Field>
495 <Field label="Price meter" hint="Optional">
496 <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
497 </Field>
498 <Field label="Own meter" hint="Optional">
499 <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
500 </Field>
501 <Field label="Note">
502 <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
503 </Field>
504 <div className="flex flex-col justify-end gap-2">
505 <label className="flex items-center gap-2 text-sm text-muted">
506 <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
507 </label>
508 <div className="flex gap-2">
509 <Button type="submit">Save mapping</Button>
510 <Button type="submit" name="remove" value="1" variant="danger">
511 Remove
512 </Button>
513 </div>
514 </div>
515 {failed?.section === "mapping" && (
516 <div className="sm:col-span-2 lg:col-span-4">
517 <Notice tone="error">{failed.error}</Notice>
518 </div>
519 )}
520 </form>
521 </Section>
522
523 <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
524 <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
525 <input type="hidden" name="intent" value="settings" />
526 <Field label="Apply small moves on their own">
527 <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
528 <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
529 </label>
530 </Field>
531 <Field label="Up to, either way, %">
532 <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
533 </Field>
534 <Field label="Notice for a rise, days">
535 <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
536 </Field>
537 <Field label="Margin floor, %">
538 <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
539 </Field>
540 <Field label="Alert after, days in a row">
541 <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
542 </Field>
543 <Field label="Ignore days costing under, $">
544 <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
545 </Field>
546 <Field label="Flag a workspace costing more than its revenue ×">
547 <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
548 </Field>
549 <Field label="And at least, $">
550 <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
551 </Field>
552 <div className="sm:col-span-2 lg:col-span-4">
553 {failed?.section === "settings" && (
554 <div className="mb-3">
555 <Notice tone="error">{failed.error}</Notice>
556 </div>
557 )}
558 <Button type="submit">Save guardrails</Button>
559 </div>
560 </form>
561 </Section>
562 </main>
563 );
564}
565
566function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
567 const rise = p.proposedCostMicros > p.currentCostMicros;
568 const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
569 return (
570 <li className="rounded-md border border-line px-4 py-3">
571 <div className="flex flex-wrap items-baseline justify-between gap-2">
572 <div className="min-w-0">
573 <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
574 </div>
575 <div className="flex items-center gap-2">
576 {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
577 <Badge tone={statusTone}>{p.status}</Badge>
578 </div>
579 </div>
580 <p className="tabular mt-1.5 text-sm">
581 Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
582 <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
583 </p>
584 <p className="mt-1 text-xs text-muted">{p.reason}</p>
585 <p className="mt-1 text-xs text-faint">
586 From the {p.source}, <When at={p.createdAt} time />
587 {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
588 {p.effectiveAt ? (
589 <>
590 {" "}
591 · in force from <When at={p.effectiveAt} />
592 </>
593 ) : null}
594 {p.note ? ` · “${p.note}”` : ""}
595 </p>
596 {p.status === "open" && (
597 <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
598 <input type="hidden" name="intent" value="decide" />
599 <input type="hidden" name="id" value={p.id} />
600 <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
601 <div className="flex gap-2">
602 <Button type="submit" name="decision" value="approve" variant="lavender">
603 Approve
604 </Button>
605 <Button type="submit" name="decision" value="reject" variant="quiet">
606 Reject
607 </Button>
608 </div>
609 </form>
610 )}
611 {error && (
612 <div className="mt-2">
613 <Notice tone="error">{error}</Notice>
614 </div>
615 )}
616 </li>
617 );
618}
619
620function CapMeter({ label, used, cap, hint }: { label: string; used: number; cap: number; hint: ReactNode }) {
621 const percent = capPercent(used, cap);
622 const tone = cap > 0 && used >= cap ? "bg-danger" : percent >= 75 ? "bg-warn" : "bg-merged";
623 return (
624 <div className="rounded-md border border-line px-4 py-3">
625 <div className="flex flex-wrap items-baseline justify-between gap-2">
626 <span className="text-sm font-medium">{label}</span>
627 <span className="tabular text-sm">
628 {usd(used)} <span className="text-faint">of {cap > 0 ? usd(cap) : "no cap"}</span>
629 </span>
630 </div>
631 <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line" role="meter" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(percent)} aria-label={label}>
632 <div className={`h-full ${tone}`} style={{ width: `${percent}%` }} />
633 </div>
634 <p className="mt-1.5 text-xs text-muted">{hint}</p>
635 </div>
636 );
637}
638
639/** g1t's own spend: the daily breaker, comped budgets, and this month by what paid. */
640function SpendSection({ caps, error }: { caps: CostsReport["caps"]; error: string | null }) {
641 const { rows, totalMicros } = spendRows(caps);
642 const net = caps.revenueMicros - totalMicros;
643 return (
644 <Section
645 className="mt-6"
646 id="spend"
647 title="g1t's own spend"
648 description="What g1t pays for itself, at cost: comped accounts, the trial and open-source pools, free workspaces' overruns, and anything charged without real money behind it. Two caps hold it: each comped account's monthly budget, and a daily breaker on all of it that pauses new hosted-model agent runs g1t would pay for."
649 >
650 <div className="grid gap-3 lg:grid-cols-2">
651 <CapMeter
652 label={`Today, ${caps.day} (UTC)`}
653 used={caps.todayMicros}
654 cap={caps.dailyCapMicros}
655 hint={
656 caps.tripped ? (
657 <span className="text-danger">
658 Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new hosted-model runs g1t pays for wait until 00:00 UTC.
659 </span>
660 ) : caps.liftedBy ? (
661 <>
662 Lifted for today by {caps.liftedBy}
663 {caps.liftNote ? `: “${caps.liftNote}”` : ""}.
664 </>
665 ) : (
666 "Paying workspaces on a live card are never paused. PLATFORM_DAILY_SPEND_CAP_MICROS."
667 )
668 }
669 />
670 {caps.comped.map((b) => (
671 <CapMeter
672 key={b.account}
673 label={`${b.name}, ${caps.month} (comped)`}
674 used={b.usedMicros}
675 cap={b.ceilingMicros}
676 hint={
677 <>
678 {b.ceilingMicros > 0 && b.usedMicros >= b.ceilingMicros ? (
679 <span className="text-danger">Used up: new work on it is refused. </span>
680 ) : null}
681 {b.defaultCeiling ? "The default budget (COMPED_MONTHLY_CEILING_MICROS)" : "Its own budget, in its terms"}. Raise it on{" "}
682 <Link to={`/workspaces/${encodeURIComponent(b.name)}#billing`} className="underline underline-offset-2">
683 its account
684 </Link>
685 : Terms, Limit.
686 </>
687 }
688 />
689 ))}
690 </div>
691 {(caps.tripped || error) && (
692 <form method="post" action="#spend" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
693 <input type="hidden" name="intent" value="lift" />
694 <Field label="Why lift it" hint="Recorded in the audit log.">
695 <Input name="note" required minLength={5} maxLength={500} placeholder="e.g. Launch day; watching it" className="sm:min-w-[24rem]" />
696 </Field>
697 <Button type="submit" variant="danger">
698 Lift for today
699 </Button>
700 </form>
701 )}
702 {error && (
703 <div className="mt-3">
704 <Notice tone="error">{error}</Notice>
705 </div>
706 )}
707 <div className="-mx-4 mt-5 overflow-x-auto sm:-mx-5">
708 <table className="w-full min-w-[34rem] text-sm">
709 <thead>
710 <tr className="border-b border-line text-left text-xs text-muted">
711 <th className="px-4 py-2 font-medium sm:px-5">{caps.month}, so far</th>
712 <th className="px-4 py-2 text-right font-medium sm:pr-5">g1t paid</th>
713 </tr>
714 </thead>
715 <tbody>
716 {rows.map((row) => (
717 <tr key={row.key} className="border-b border-line align-top">
718 <td className="px-4 py-2.5 sm:px-5">
719 {row.title}
720 <span className="block text-xs text-faint">{row.note}</span>
721 </td>
722 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(row.micros)}</td>
723 </tr>
724 ))}
725 <tr className="border-b border-line font-medium">
726 <td className="px-4 py-2.5 sm:px-5">All of it</td>
727 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(totalMicros)}</td>
728 </tr>
729 <tr className="border-b border-line">
730 <td className="px-4 py-2.5 sm:px-5">
731 Money in
732 <span className="block text-xs text-faint">Usage paid for and the plan, reconciled through yesterday</span>
733 </td>
734 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td>
735 </tr>
736 <tr>
737 <td className="px-4 py-2.5 sm:px-5">Money in less what g1t paid</td>
738 <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${net < 0 ? "text-danger" : "text-fg-soft"}`}>{usd(net, { signed: true })}</td>
739 </tr>
740 </tbody>
741 </table>
742 </div>
743 </Section>
744 );
745}
746
747function MappingsTable({ report }: { report: CostsReport }) {
748 return (
749 <div className="-mx-4 overflow-x-auto sm:-mx-5">
750 <table className="w-full min-w-[44rem] text-sm">
751 <thead>
752 <tr className="border-b border-line text-left text-xs text-muted">
753 <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
754 <th className="px-4 py-2 font-medium">g1t product</th>
755 <th className="px-4 py-2 font-medium">Price meter</th>
756 <th className="px-4 py-2 font-medium">Own meter</th>
757 <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
758 </tr>
759 </thead>
760 <tbody>
761 {report.mappings.map((m) => (
762 <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
763 <td className="px-4 py-2 sm:px-5">
764 <span className="font-mono text-xs">
765 {m.product} / {m.meter}
766 </span>
767 {m.note && <span className="block text-xs text-faint">{m.note}</span>}
768 </td>
769 <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
770 <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
771 <td className="px-4 py-2 font-mono text-xs text-muted">
772 {m.ownMeter ?? "—"}
773 {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
774 </td>
775 <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
776 </tr>
777 ))}
778 </tbody>
779 </table>
780 </div>
781 );
782}