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

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