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

906 lines44,220 bytesCodeBlame
1import type { ReactNode } from "react";
2import { Form, Link, data, redirect, useNavigation } 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: "Ran the analysis: read Cloudflare's bill, reconciled the last 31 days and checked the alerts. 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 g1t cost to run (Cloudflare's bill and the model providers') against what workspaces paid. 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={<RunButton />}
152 />
153 <nav aria-label="Range" className="mt-5 flex flex-wrap gap-2">
154 {RANGES.map((days) => (
155 <Link key={days} to={href(days, bucket)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}>
156 Last {days} days
157 </Link>
158 ))}
159 </nav>
160 <div className="mt-5 space-y-3 empty:hidden">
161 {done && <Notice tone="ok">{done}</Notice>}
162 {failed?.section === "run" && <Notice tone="error">{failed.error}</Notice>}
163 {!report.configured && (
164 <Notice tone="warn">
165 Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account:
166 Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing.
167 </Notice>
168 )}
169 {banner.map((alert) => (
170 <Notice key={alert.id} tone="error">
171 {alert.detail} <span className="text-faint">Since {alert.since}.</span>
172 </Notice>
173 ))}
174 {elsewhere > 0 && (
175 <p className="text-xs text-muted">
176 {elsewhere} more open: {" "}
177 <a href="#drift" className="underline underline-offset-2">
178 drift and leaks
179 </a>{" "}
180 below, and workspaces that cost more than they pay on{" "}
181 <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2">
182 Reach out
183 </Link>
184 .
185 </p>
186 )}
187 </div>
188
189 <Statement report={report} floor={floor} range={range} proposals={open.length} />
190
191 <SpendSection caps={report.caps} error={failed?.section === "lift" ? failed.error : null} />
192
193 <Section
194 className="mt-6"
195 title={product ? `${product.title}, by day` : "By day"}
196 description={
197 product
198 ? "What customers were charged for it at price, before included usage and pools paid part, against what it cost."
199 : "Money in (usage paid for and the plan) against every cost. Pick a product to see its own."
200 }
201 >
202 <nav aria-label="Product" className="mb-4 flex flex-wrap gap-2">
203 <Link to={href(range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}>
204 All of g1t
205 </Link>
206 {report.products.map((p) => (
207 <Link key={p.bucket} to={href(range, p.bucket)} aria-current={p.bucket === bucket ? "page" : undefined} className={chip(p.bucket === bucket)}>
208 {p.title}
209 </Link>
210 ))}
211 </nav>
212 <DaysChart
213 days={series}
214 floor={floor}
215 revenueLabel={product ? "Charged at price" : "Money in"}
216 label={product ? `${product.title}: charged against Cloudflare cost by day` : "Money in against Cloudflare cost by day"}
217 />
218 </Section>
219
220 <Section className="mt-6" title="By product" description={`Each of g1t's products over the last ${range} days. The floor is ${floor}%.`}>
221 <div className="-mx-4 overflow-x-auto sm:-mx-5">
222 <table className="w-full min-w-[44rem] text-sm">
223 <thead>
224 <tr className="border-b border-line text-left text-xs text-muted">
225 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
226 <th className="px-4 py-2 text-right font-medium">Charged at price</th>
227 <th className="px-4 py-2 text-right font-medium">Cost</th>
228 <th className="px-4 py-2 text-right font-medium">Price book said</th>
229 <th className="px-4 py-2 text-right font-medium">Margin</th>
230 <th className="px-4 py-2 text-right font-medium sm:pr-5">%</th>
231 </tr>
232 </thead>
233 <tbody>
234 {report.products.map((p) => {
235 const tone = p.overhead ? undefined : marginTone(p.marginPercent, floor);
236 return (
237 <tr key={p.bucket} className="border-b border-line last:border-0">
238 <td className="px-4 py-2.5 sm:px-5">
239 <Link to={href(range, p.bucket)} className="text-fg hover:underline">
240 {p.title}
241 </Link>
242 <span className="block text-xs text-faint">
243 {p.overhead ? "Paid by the plan" : p.costSource === "ledger" ? "Cost from the gateway, not Cloudflare" : p.bucket}
244 </span>
245 </td>
246 <td className="tabular px-4 py-2.5 text-right">{usd(p.valueMicros)}</td>
247 <td className="tabular px-4 py-2.5 text-right">{usd(p.costMicros)}</td>
248 <td className="tabular px-4 py-2.5 text-right text-muted">{usd(p.ownCostMicros)}</td>
249 <td className={`tabular px-4 py-2.5 text-right ${p.marginMicros < 0 && !p.overhead ? "text-danger" : ""}`}>{usd(p.marginMicros)}</td>
250 <td className="px-4 py-2.5 text-right sm:pr-5">
251 {p.overhead ? (
252 <span className="text-faint">—</span>
253 ) : (
254 <Badge tone={tone === "mint" ? "mint" : tone === "warn" ? "warn" : tone === "danger" ? "danger" : "plain"}>{percentLabel(p.marginPercent)}</Badge>
255 )}
256 </td>
257 </tr>
258 );
259 })}
260 </tbody>
261 </table>
262 </div>
263 </Section>
264
265 <Section
266 className="mt-6"
267 id="drift"
268 title="Drift"
269 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)."
270 >
271 {report.drift.length === 0 ? (
272 <p className="text-sm text-muted">Nothing has drifted.</p>
273 ) : (
274 <div className="-mx-4 overflow-x-auto sm:-mx-5">
275 <table className="w-full min-w-[44rem] 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:px-5">Product</th>
279 <th className="px-4 py-2 font-medium">Kind</th>
280 <th className="px-4 py-2 text-right font-medium">g1t</th>
281 <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
282 <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
283 </tr>
284 </thead>
285 <tbody>
286 {report.drift.map((d) => {
287 const money = d.kind !== "count";
288 return (
289 <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
290 <td className="px-4 py-2.5 sm:px-5">
291 {d.title}
292 <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
293 </td>
294 <td className="px-4 py-2.5">
295 <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
296 </td>
297 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
298 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
299 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
300 </tr>
301 );
302 })}
303 </tbody>
304 </table>
305 </div>
306 )}
307 </Section>
308
309 <Section
310 className="mt-6"
311 id="proposals"
312 title="Price proposals"
313 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.`}
314 >
315 {report.proposals.length === 0 ? (
316 <p className="text-sm text-muted">No proposals yet.</p>
317 ) : (
318 <ul className="space-y-3">
319 {report.proposals.map((p) => (
320 <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
321 ))}
322 </ul>
323 )}
324 </Section>
325
326 <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.">
327 <div className="-mx-4 overflow-x-auto sm:-mx-5">
328 <table className="w-full min-w-[44rem] text-sm">
329 <thead>
330 <tr className="border-b border-line text-left text-xs text-muted">
331 <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
332 <th className="px-4 py-2 text-right font-medium">Cost</th>
333 <th className="px-4 py-2 text-right font-medium">Price</th>
334 <th className="px-4 py-2 font-medium">From</th>
335 <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
336 </tr>
337 </thead>
338 <tbody>
339 {report.versions.map((v) => (
340 <tr key={v.id} className="border-b border-line align-top last:border-0">
341 <td className="px-4 py-2.5 sm:px-5">
342 <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
343 {!v.appliedAt && (
344 <span className="ml-2">
345 <Badge tone="info">Coming</Badge>
346 </span>
347 )}
348 </td>
349 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
350 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
351 <td className="px-4 py-2.5 text-xs text-muted">
352 <When at={v.effectiveAt} />
353 </td>
354 <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
355 {v.reason} · {v.createdBy}
356 </td>
357 </tr>
358 ))}
359 </tbody>
360 </table>
361 </div>
362 </Section>
363
364 <Section
365 className="mt-6"
366 title="Workspaces that cost most"
367 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."
368 >
369 {report.topWorkspaces.length === 0 ? (
370 <p className="text-sm text-muted">No costs shared out yet.</p>
371 ) : (
372 <div className="-mx-4 overflow-x-auto sm:-mx-5">
373 <table className="w-full min-w-[34rem] text-sm">
374 <thead>
375 <tr className="border-b border-line text-left text-xs text-muted">
376 <th className="px-4 py-2 font-medium sm:px-5">Workspace</th>
377 <th className="px-4 py-2 text-right font-medium">Cost to g1t</th>
378 <th className="px-4 py-2 text-right font-medium">Given away</th>
379 <th className="px-4 py-2 text-right font-medium">Paid</th>
380 <th className="px-4 py-2 text-right font-medium sm:pr-5">Net</th>
381 </tr>
382 </thead>
383 <tbody>
384 {report.topWorkspaces.map((w) => {
385 const net = w.revenueMicros - w.costMicros;
386 return (
387 <tr key={w.workspace} className="border-b border-line last:border-0">
388 <td className="px-4 py-2.5 sm:px-5">
389 <Link to={`/workspaces/${encodeURIComponent(w.workspace)}#billing`} className="font-mono text-fg hover:underline">
390 {w.workspace}
391 </Link>
392 {w.internal && <span className="ml-2 text-xs text-faint">g1t's own</span>}
393 </td>
394 <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td>
395 <td className="tabular px-4 py-2.5 text-right text-fg-soft">{usd(w.givenMicros ?? 0)}</td>
396 <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td>
397 <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>
398 </tr>
399 );
400 })}
401 </tbody>
402 </table>
403 </div>
404 )}
405 </Section>
406
407 <Section
408 className="mt-6"
409 title="Cloudflare's lines"
410 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."
411 >
412 {report.lines.length === 0 ? (
413 <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Run the analysis now”.</EmptyState>
414 ) : (
415 <div className="-mx-4 overflow-x-auto sm:-mx-5">
416 <table className="w-full min-w-[48rem] text-sm">
417 <thead>
418 <tr className="border-b border-line text-left text-xs text-muted">
419 <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
420 <th className="px-4 py-2 text-right font-medium">Quantity</th>
421 <th className="px-4 py-2 text-right font-medium">Cost</th>
422 <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
423 </tr>
424 </thead>
425 <tbody>
426 {report.lines.map((l) => (
427 <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
428 <td className="px-4 py-2.5 sm:px-5">
429 <span className="font-mono text-xs">
430 {l.product} / {l.meter}
431 </span>
432 <span className="block text-xs text-faint">
433 {l.rawName}
434 {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
435 </span>
436 </td>
437 <td className="tabular px-4 py-2.5 text-right">
438 {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
439 </td>
440 <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
441 <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>
442 </tr>
443 ))}
444 </tbody>
445 </table>
446 </div>
447 )}
448 </Section>
449
450 <Section
451 className="mt-6"
452 id="mappings"
453 title="Mappings"
454 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."
455 >
456 <MappingsTable report={report} />
457 <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
458 <input type="hidden" name="intent" value="mapping" />
459 <Field label="Cloudflare product">
460 <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
461 </Field>
462 <Field label="Meter prefix" hint="Or * for all of it">
463 <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
464 </Field>
465 <Field label="g1t product">
466 <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
467 </Field>
468 <Field label="Drift threshold, %">
469 <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
470 </Field>
471 <Field label="Price meter" hint="Optional">
472 <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
473 </Field>
474 <Field label="Own meter" hint="Optional">
475 <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
476 </Field>
477 <Field label="Note">
478 <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
479 </Field>
480 <div className="flex flex-col justify-end gap-2">
481 <label className="flex items-center gap-2 text-sm text-muted">
482 <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
483 </label>
484 <div className="flex gap-2">
485 <Button type="submit">Save mapping</Button>
486 <Button type="submit" name="remove" value="1" variant="danger">
487 Remove
488 </Button>
489 </div>
490 </div>
491 {failed?.section === "mapping" && (
492 <div className="sm:col-span-2 lg:col-span-4">
493 <Notice tone="error">{failed.error}</Notice>
494 </div>
495 )}
496 </form>
497 </Section>
498
499 <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
500 <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
501 <input type="hidden" name="intent" value="settings" />
502 <Field label="Apply small moves on their own">
503 <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
504 <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
505 </label>
506 </Field>
507 <Field label="Up to, either way, %">
508 <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
509 </Field>
510 <Field label="Notice for a rise, days">
511 <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
512 </Field>
513 <Field label="Margin floor, %">
514 <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
515 </Field>
516 <Field label="Alert after, days in a row">
517 <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
518 </Field>
519 <Field label="Ignore days costing under, $">
520 <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
521 </Field>
522 <Field label="Flag a workspace costing more than its revenue ×">
523 <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
524 </Field>
525 <Field label="And at least, $">
526 <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
527 </Field>
528 <div className="sm:col-span-2 lg:col-span-4">
529 {failed?.section === "settings" && (
530 <div className="mb-3">
531 <Notice tone="error">{failed.error}</Notice>
532 </div>
533 )}
534 <Button type="submit">Save guardrails</Button>
535 </div>
536 </form>
537 </Section>
538 </main>
539 );
540}
541
542function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
543 const rise = p.proposedCostMicros > p.currentCostMicros;
544 const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
545 return (
546 <li className="rounded-md border border-line px-4 py-3">
547 <div className="flex flex-wrap items-baseline justify-between gap-2">
548 <div className="min-w-0">
549 <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
550 </div>
551 <div className="flex items-center gap-2">
552 {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
553 <Badge tone={statusTone}>{p.status}</Badge>
554 </div>
555 </div>
556 <p className="tabular mt-1.5 text-sm">
557 Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
558 <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
559 </p>
560 <p className="mt-1 text-xs text-muted">{p.reason}</p>
561 <p className="mt-1 text-xs text-faint">
562 From the {p.source}, <When at={p.createdAt} time />
563 {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
564 {p.effectiveAt ? (
565 <>
566 {" "}
567 · in force from <When at={p.effectiveAt} />
568 </>
569 ) : null}
570 {p.note ? ` · “${p.note}”` : ""}
571 </p>
572 {p.status === "open" && (
573 <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
574 <input type="hidden" name="intent" value="decide" />
575 <input type="hidden" name="id" value={p.id} />
576 <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
577 <div className="flex gap-2">
578 <Button type="submit" name="decision" value="approve" variant="lavender">
579 Approve
580 </Button>
581 <Button type="submit" name="decision" value="reject" variant="quiet">
582 Reject
583 </Button>
584 </div>
585 </form>
586 )}
587 {error && (
588 <div className="mt-2">
589 <Notice tone="error">{error}</Notice>
590 </div>
591 )}
592 </li>
593 );
594}
595
596function CapMeter({ label, used, cap, hint }: { label: string; used: number; cap: number; hint: ReactNode }) {
597 const percent = capPercent(used, cap);
598 const tone = cap > 0 && used >= cap ? "bg-danger" : percent >= 75 ? "bg-warn" : "bg-merged";
599 return (
600 <div className="rounded-md border border-line px-4 py-3">
601 <div className="flex flex-wrap items-baseline justify-between gap-2">
602 <span className="text-sm font-medium">{label}</span>
603 <span className="tabular text-sm">
604 {usd(used)} <span className="text-faint">of {cap > 0 ? usd(cap) : "no cap"}</span>
605 </span>
606 </div>
607 <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}>
608 <div className={`h-full ${tone}`} style={{ width: `${percent}%` }} />
609 </div>
610 <p className="mt-1.5 text-xs text-muted">{hint}</p>
611 </div>
612 );
613}
614
615/** g1t's own spend: the daily breaker, comped budgets, and this month by what paid. */
616/** The button that runs the nightly analysis now, saying so while it runs. */
617function RunButton() {
618 const navigation = useNavigation();
619 const running = navigation.state !== "idle" && navigation.formData?.get("intent") === "run";
620 return (
621 <Form method="post" className="flex items-center gap-3">
622 <input type="hidden" name="intent" value="run" />
623 <Button variant="quiet" type="submit" disabled={running}>
624 {running ? "Running the analysis…" : "Run the analysis now"}
625 </Button>
626 {running && <span className="text-xs text-muted">Reading the bill and reconciling 31 days; about a minute.</span>}
627 </Form>
628 );
629}
630
631/**
632 * Where the money went, as a short statement: usage sold and running g1t
633 * (each against what paid for it), what g1t gave away on purpose (a budget,
634 * never a loss), and who was paid.
635 */
636function Statement({ report, floor, range, proposals }: { report: CostsReport; floor: number; range: number; proposals: number }) {
637 const o = report.overall;
638 // Reports from before the statement have only the totals.
639 const given = o.givenMicros ?? 0;
640 const usageCost = o.usageCostMicros ?? o.costMicros - given;
641 const usageMargin = o.usageMarginMicros ?? o.usageMicros - usageCost;
642 const usagePercent = o.usageMarginPercent !== undefined ? o.usageMarginPercent : null;
643 const running = o.runningCostMicros ?? 0;
644 const unmapped = o.unmappedCostMicros ?? 0;
645 // Cloudflare's subscriptions are not on the usage bill: the estimate, over the range.
646 const subscriptions = Math.round((report.caps.fixedMonthlyMicros * range) / 30);
647 const moneyIn = o.usageMicros + o.plansMicros;
648 const spent = o.costMicros + subscriptions;
649 const net = moneyIn - spent;
650 const givenParts = [
651 ["comped", o.givenCompedMicros ?? 0],
652 ["free use", o.givenFreeMicros ?? 0],
653 ["trial", o.givenTrialMicros ?? 0],
654 ["open-source pool", o.givenPoolMicros ?? 0],
655 ].filter(([, micros]) => (micros as number) > 0) as [string, number][];
656 const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [
657 {
658 title: "Usage sold",
659 note: "What workspaces paid for usage, against what that usage cost",
660 in: o.usageMicros,
661 cost: usageCost,
662 result: usageMargin,
663 tone: usageMargin < 0 ? "danger" : undefined,
664 },
665 {
666 title: "Running g1t",
667 note: "Plans, against Workers, D1, KV, Queues and the rest of the platform",
668 in: o.plansMicros,
669 cost: running,
670 result: o.plansMicros - running,
671 tone: o.plansMicros - running < 0 ? "warn" : undefined,
672 },
673 {
674 title: "Cloudflare subscriptions",
675 note: "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS), not on the usage bill",
676 in: null,
677 cost: subscriptions,
678 result: -subscriptions,
679 tone: "muted",
680 },
681 ...(unmapped > 0
682 ? [{ title: "Not mapped", note: "Billed by Cloudflare, charged for by nothing yet", in: null, cost: unmapped, result: -unmapped, tone: "danger" as const }]
683 : []),
684 {
685 title: "Given away",
686 note: givenParts.length > 0 ? givenParts.map(([why, micros]) => `${why} ${usd(micros)}`).join(" · ") : "Nothing this range",
687 in: null,
688 cost: given,
689 result: null,
690 tone: "muted",
691 },
692 ];
693 const resultClass = (tone?: string) => (tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "muted" ? "text-muted" : "text-fg");
694 return (
695 <>
696 <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
697 <Stat
698 label="Margin on usage sold"
699 value={percentLabel(usagePercent)}
700 hint={`${usd(o.usageMicros)} paid for usage that cost ${usd(usageCost)}`}
701 tone={marginTone(usagePercent, floor)}
702 />
703 <Stat
704 label="Given away on purpose"
705 value={usd(given)}
706 hint={givenParts.length > 0 ? givenParts.map(([why, micros]) => `${usd(micros)} ${why}`).join(", ") : "Nothing given this range"}
707 />
708 <Stat
709 label="Who g1t paid"
710 value={usd(o.costMicros)}
711 hint={`Cloudflare ${usd(o.cloudflareCostMicros ?? o.costMicros - (o.modelsCostMicros ?? 0))}, model providers ${usd(o.modelsCostMicros ?? 0)}`}
712 />
713 <Stat
714 label="Proposals waiting"
715 value={String(proposals)}
716 hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"}
717 tone={proposals > 0 ? "warn" : undefined}
718 />
719 </div>
720 <div className="mt-3 overflow-x-auto rounded-lg border border-line bg-surface">
721 <table className="w-full min-w-[36rem] text-sm">
722 <thead>
723 <tr className="border-b border-line text-left text-xs text-muted">
724 <th className="px-4 py-2 font-medium sm:px-5">
725 {report.since} to {report.until}
726 </th>
727 <th className="px-4 py-2 text-right font-medium">Paid in</th>
728 <th className="px-4 py-2 text-right font-medium">Cost</th>
729 <th className="px-4 py-2 text-right font-medium sm:pr-5">Result</th>
730 </tr>
731 </thead>
732 <tbody>
733 {rows.map((row) => (
734 <tr key={row.title} className="border-b border-line">
735 <td className="px-4 py-2.5 sm:px-5">
736 <span className="text-fg">{row.title}</span>
737 <span className="block text-xs text-faint">{row.note}</span>
738 </td>
739 <td className="tabular px-4 py-2.5 text-right">{row.in == null ? <span className="text-faint">—</span> : usd(row.in)}</td>
740 <td className="tabular px-4 py-2.5 text-right">{usd(row.cost)}</td>
741 <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${resultClass(row.tone)}`}>
742 {row.result == null ? "a budget" : usd(row.result, { signed: true })}
743 </td>
744 </tr>
745 ))}
746 <tr>
747 <td className="px-4 py-2.5 sm:px-5">
748 <span className="font-medium text-fg">All in</span>
749 <span className="block text-xs text-faint">
750 {given > 0 ? `${usd(given)} of the cost was given away on purpose; without it, ${usd(net + given, { signed: true })}` : "Everything above"}
751 </span>
752 </td>
753 <td className="tabular px-4 py-2.5 text-right font-medium">{usd(moneyIn)}</td>
754 <td className="tabular px-4 py-2.5 text-right font-medium">{usd(spent)}</td>
755 <td className="tabular px-4 py-2.5 text-right font-medium text-fg sm:pr-5">{usd(net, { signed: true })}</td>
756 </tr>
757 </tbody>
758 </table>
759 </div>
760 </>
761 );
762}
763
764function SpendSection({ caps, error }: { caps: CostsReport["caps"]; error: string | null }) {
765 const { rows, totalMicros } = spendRows(caps);
766 const net = caps.revenueMicros - totalMicros;
767 return (
768 <Section
769 className="mt-6"
770 id="spend"
771 title="g1t's own spend"
772 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."
773 >
774 <div className="grid gap-3 lg:grid-cols-2">
775 <CapMeter
776 label={`Today, ${caps.day} (UTC)`}
777 used={caps.todayMicros}
778 cap={caps.dailyCapMicros}
779 hint={
780 caps.tripped ? (
781 <span className="text-danger">
782 Breaker open{caps.trippedAt ? <> since <When at={caps.trippedAt} time /></> : null}: new hosted-model runs g1t pays for wait until 00:00 UTC.
783 </span>
784 ) : caps.liftedBy ? (
785 <>
786 Lifted for today by {caps.liftedBy}
787 {caps.liftNote ? `: “${caps.liftNote}”` : ""}.
788 </>
789 ) : (
790 "Paying workspaces on a live card are never paused. PLATFORM_DAILY_SPEND_CAP_MICROS."
791 )
792 }
793 />
794 {caps.comped.map((b) => (
795 <CapMeter
796 key={b.account}
797 label={`${b.name}, ${caps.month} (comped)`}
798 used={b.usedMicros}
799 cap={b.ceilingMicros}
800 hint={
801 <>
802 {b.ceilingMicros > 0 && b.usedMicros >= b.ceilingMicros ? (
803 <span className="text-danger">Used up: new work on it is refused. </span>
804 ) : null}
805 {b.defaultCeiling ? "The default budget (COMPED_MONTHLY_CEILING_MICROS)" : "Its own budget, in its terms"}. Raise it on{" "}
806 <Link to={`/workspaces/${encodeURIComponent(b.name)}#billing`} className="underline underline-offset-2">
807 its account
808 </Link>
809 : Terms, Limit.
810 </>
811 }
812 />
813 ))}
814 </div>
815 {(caps.tripped || error) && (
816 <form method="post" action="#spend" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end">
817 <input type="hidden" name="intent" value="lift" />
818 <Field label="Why lift it" hint="Recorded in the audit log.">
819 <Input name="note" required minLength={5} maxLength={500} placeholder="e.g. Launch day; watching it" className="sm:min-w-[24rem]" />
820 </Field>
821 <Button type="submit" variant="danger">
822 Lift for today
823 </Button>
824 </form>
825 )}
826 {error && (
827 <div className="mt-3">
828 <Notice tone="error">{error}</Notice>
829 </div>
830 )}
831 <div className="-mx-4 mt-5 overflow-x-auto sm:-mx-5">
832 <table className="w-full min-w-[34rem] text-sm">
833 <thead>
834 <tr className="border-b border-line text-left text-xs text-muted">
835 <th className="px-4 py-2 font-medium sm:px-5">{caps.month}, so far</th>
836 <th className="px-4 py-2 text-right font-medium sm:pr-5">g1t paid</th>
837 </tr>
838 </thead>
839 <tbody>
840 {rows.map((row) => (
841 <tr key={row.key} className="border-b border-line align-top">
842 <td className="px-4 py-2.5 sm:px-5">
843 {row.title}
844 <span className="block text-xs text-faint">{row.note}</span>
845 </td>
846 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(row.micros)}</td>
847 </tr>
848 ))}
849 <tr className="border-b border-line font-medium">
850 <td className="px-4 py-2.5 sm:px-5">All of it</td>
851 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(totalMicros)}</td>
852 </tr>
853 <tr className="border-b border-line">
854 <td className="px-4 py-2.5 sm:px-5">
855 Money in
856 <span className="block text-xs text-faint">Usage paid for and the plan, reconciled through yesterday</span>
857 </td>
858 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td>
859 </tr>
860 <tr>
861 <td className="px-4 py-2.5 sm:px-5">Money in less what g1t paid</td>
862 <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>
863 </tr>
864 </tbody>
865 </table>
866 </div>
867 </Section>
868 );
869}
870
871function MappingsTable({ report }: { report: CostsReport }) {
872 return (
873 <div className="-mx-4 overflow-x-auto sm:-mx-5">
874 <table className="w-full min-w-[44rem] text-sm">
875 <thead>
876 <tr className="border-b border-line text-left text-xs text-muted">
877 <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
878 <th className="px-4 py-2 font-medium">g1t product</th>
879 <th className="px-4 py-2 font-medium">Price meter</th>
880 <th className="px-4 py-2 font-medium">Own meter</th>
881 <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
882 </tr>
883 </thead>
884 <tbody>
885 {report.mappings.map((m) => (
886 <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
887 <td className="px-4 py-2 sm:px-5">
888 <span className="font-mono text-xs">
889 {m.product} / {m.meter}
890 </span>
891 {m.note && <span className="block text-xs text-faint">{m.note}</span>}
892 </td>
893 <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
894 <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
895 <td className="px-4 py-2 font-mono text-xs text-muted">
896 {m.ownMeter ?? "—"}
897 {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
898 </td>
899 <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
900 </tr>
901 ))}
902 </tbody>
903 </table>
904 </div>
905 );
906}