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

641 lines31,594 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1import { Link, data, redirect } from "react-router";
2
3import type { CostsReport, PriceProposal } from "@g1t/contracts";
4
5import type { Route } from "./+types/costs";
6import { DaysChart } from "~/components/costs";
7import { Badge, Button, EmptyState, Field, Input, Notice, PageHeader, Section, Stat, When } from "~/components/ui";
8import {
9 countLabel,
10 daySeries,
11 driftLabel,
12 marginTone,
13 parseBucket,
14 parseCostSettings,
15 parseMapping,
16 parseRange,
17 percentLabel,
18 unitDollars,
19} from "~/lib/costs";
20import { dollarsField, usd } from "~/lib/money";
21import { admin } from "~/lib/services.server";
22import { settle } from "~/lib/settle";
23import { requireStaff } from "~/lib/staff";
24
25export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }];
26
27const DONE: Record<string, string> = {
28 run: "Read Cloudflare's bill and reconciled. The figures below are fresh.",
29 approved: "Approved. A fall applies now; a rise after the notice period, and owners on the plan are emailed.",
30 rejected: "Rejected, with the note kept for whoever measures it next.",
31 settings: "Guardrails saved. They apply from the next run.",
32 mapping: "Mapping saved. It applies from the next run; read the bill now to see it.",
33 removed: "Mapping removed.",
34};
35
36const RANGES = [7, 30, 90];
37
38export async function loader({ request, context }: Route.LoaderArgs) {
39 requireStaff(context);
40 const url = new URL(request.url);
41 const range = parseRange(url.searchParams.get("days"));
42 const report = await settle(admin.costs(range));
43 const done = url.searchParams.get("done");
44 return {
45 range,
46 bucket: url.searchParams.get("product"),
47 report: report.ok ? report.value : null,
48 error: report.ok ? null : report.error,
49 done: done ? (DONE[done] ?? null) : null,
50 };
51}
52
53type ActionResult = { error: string; section: string; values?: Record<string, string> };
54
55export async function action({ request, context }: Route.ActionArgs) {
56 const staff = requireStaff(context);
57 const form = await request.formData();
58 const intent = String(form.get("intent") ?? "");
59 const fail = (section: string, error: string) =>
60 data<ActionResult>({ error, section, values: Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])) }, { status: 422 });
61 const back = (done: string, hash = "") => {
62 const url = new URL(request.url);
63 url.searchParams.set("done", done);
64 return redirect(`${url.pathname}${url.search}${hash}`);
65 };
66 if (intent === "run") {
67 const run = await settle(admin.runCosts(staff.email));
68 if (!run.ok) return fail("run", `Billing did not answer: ${run.error}`);
69 if (!run.value.ok) return fail("run", run.value.error.message);
70 if (run.value.value.problems.length > 0) return fail("run", run.value.value.problems.join(" "));
71 throw back("run");
72 }
73 if (intent === "decide") {
74 const id = String(form.get("id") ?? "");
75 const decision = form.get("decision") === "approve" ? "approve" : "reject";
76 const note = String(form.get("note") ?? "").trim().slice(0, 500);
77 if (decision === "reject" && note.length < 5) return fail(`proposal-${id}`, "Say why it is rejected, for whoever measures it next.");
78 const result = await admin.decideProposal(id, decision, note, staff.email);
79 if (!result.ok) return fail(`proposal-${id}`, result.error.message);
80 throw back(decision === "approve" ? "approved" : "rejected", "#proposals");
81 }
82 if (intent === "settings") {
83 const parsed = parseCostSettings(form);
84 if (!parsed.ok) return fail("settings", parsed.error);
85 const result = await admin.setCostSettings(parsed.value, staff.email);
86 if (!result.ok) return fail("settings", result.error.message);
87 throw back("settings", "#guardrails");
88 }
89 if (intent === "mapping") {
90 const parsed = parseMapping(form);
91 if (!parsed.ok) return fail("mapping", parsed.error);
92 const result = await admin.setCostMapping(parsed.value, staff.email);
93 if (!result.ok) return fail("mapping", result.error.message);
94 throw back(parsed.value.remove ? "removed" : "mapping", "#mappings");
95 }
96 return fail("run", "Unknown change.");
97}
98
99function chip(active: boolean) {
100 return `inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${
101 active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg"
102 }`;
103}
104
105function href(range: number, bucket: string | null) {
106 const params = new URLSearchParams();
107 if (range !== 30) params.set("days", String(range));
108 if (bucket) params.set("product", bucket);
109 const query = params.toString();
110 return query ? `/costs?${query}` : "/costs";
111}
112
113export default function Costs({ loaderData, actionData }: Route.ComponentProps) {
114 const { range, report, error, done } = loaderData;
115 const failed = actionData && "error" in actionData ? (actionData as ActionResult) : null;
116 if (!report) {
117 return (
118 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
119 <PageHeader title="Costs & margin" />
120 <div className="mt-5">
121 <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
122 </div>
123 </main>
124 );
125 }
126 const bucket = parseBucket(loaderData.bucket, report.products.map((p) => p.bucket));
127 const product = report.products.find((p) => p.bucket === bucket) ?? null;
128 const series = daySeries(report.days, report.since, report.until, bucket);
129 const floor = report.settings.marginFloorPercent;
130 const open = report.proposals.filter((p) => p.status === "open");
131 // Margin under the floor at the top; drift and leaks are in their own table, workspaces on Reach out.
132 const banner = report.alerts.filter((a) => a.kind === "overall" || a.kind === "margin");
133 const elsewhere = report.alerts.length - banner.length;
134 return (
135 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
136 <PageHeader
137 title="Costs & margin"
138 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."
139 actions={
140 <form method="post">
141 <input type="hidden" name="intent" value="run" />
142 <Button variant="quiet" type="submit">
143 Read the bill now
144 </Button>
145 </form>
146 }
147 />
148 <nav aria-label="Range" className="mt-5 flex flex-wrap gap-2">
149 {RANGES.map((days) => (
150 <Link key={days} to={href(days, bucket)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}>
151 Last {days} days
152 </Link>
153 ))}
154 </nav>
155 <div className="mt-5 space-y-3 empty:hidden">
156 {done && <Notice tone="ok">{done}</Notice>}
157 {failed?.section === "run" && <Notice tone="error">{failed.error}</Notice>}
158 {!report.configured && (
159 <Notice tone="warn">
160 Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account:
161 Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing.
162 </Notice>
163 )}
164 {banner.map((alert) => (
165 <Notice key={alert.id} tone="error">
166 {alert.detail} <span className="text-faint">Since {alert.since}.</span>
167 </Notice>
168 ))}
169 {elsewhere > 0 && (
170 <p className="text-xs text-muted">
171 {elsewhere} more open: {" "}
172 <a href="#drift" className="underline underline-offset-2">
173 drift and leaks
174 </a>{" "}
175 below, and workspaces that cost more than they pay on{" "}
176 <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2">
177 Reach out
178 </Link>
179 .
180 </p>
181 )}
182 </div>
183
184 <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
185 <Stat
186 label="Money in"
187 value={usd(report.overall.usageMicros + report.overall.plansMicros)}
188 hint={`${usd(report.overall.usageMicros)} usage, ${usd(report.overall.plansMicros)} plans`}
189 />
190 <Stat label="Cloudflare cost" value={usd(report.overall.costMicros)} hint={`${report.since} to ${report.until}`} />
191 <Stat
192 label="Margin"
193 value={percentLabel(report.overall.marginPercent)}
194 hint={usd(report.overall.marginMicros)}
195 tone={marginTone(report.overall.marginPercent, floor)}
196 />
197 <Stat
198 label="Proposals waiting"
199 value={String(open.length)}
200 hint={report.fetchedAt ? <>Bill read <When at={report.fetchedAt} time /></> : "The bill has not been read yet"}
201 tone={open.length > 0 ? "warn" : undefined}
202 />
203 </div>
204
205 <Section
206 className="mt-6"
207 title={product ? `${product.title}, by day` : "By day"}
208 description={
209 product
210 ? "What customers were charged for it at price, before included usage and pools paid part, against what it cost."
211 : "Money in (usage paid for and the plan) against every cost. Pick a product to see its own."
212 }
213 >
214 <nav aria-label="Product" className="mb-4 flex flex-wrap gap-2">
215 <Link to={href(range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}>
216 All of g1t
217 </Link>
218 {report.products.map((p) => (
219 <Link key={p.bucket} to={href(range, p.bucket)} aria-current={p.bucket === bucket ? "page" : undefined} className={chip(p.bucket === bucket)}>
220 {p.title}
221 </Link>
222 ))}
223 </nav>
224 <DaysChart
225 days={series}
226 floor={floor}
227 revenueLabel={product ? "Charged at price" : "Money in"}
228 label={product ? `${product.title}: charged against Cloudflare cost by day` : "Money in against Cloudflare cost by day"}
229 />
230 </Section>
231
232 <Section className="mt-6" title="By product" description={`Each of g1t's products over the last ${range} days. The floor is ${floor}%.`}>
233 <div className="-mx-4 overflow-x-auto sm:-mx-5">
234 <table className="w-full min-w-[44rem] text-sm">
235 <thead>
236 <tr className="border-b border-line text-left text-xs text-muted">
237 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
238 <th className="px-4 py-2 text-right font-medium">Charged at price</th>
239 <th className="px-4 py-2 text-right font-medium">Cost</th>
240 <th className="px-4 py-2 text-right font-medium">Price book said</th>
241 <th className="px-4 py-2 text-right font-medium">Margin</th>
242 <th className="px-4 py-2 text-right font-medium sm:pr-5">%</th>
243 </tr>
244 </thead>
245 <tbody>
246 {report.products.map((p) => {
247 const tone = p.overhead ? undefined : marginTone(p.marginPercent, floor);
248 return (
249 <tr key={p.bucket} className="border-b border-line last:border-0">
250 <td className="px-4 py-2.5 sm:px-5">
251 <Link to={href(range, p.bucket)} className="text-fg hover:underline">
252 {p.title}
253 </Link>
254 <span className="block text-xs text-faint">
255 {p.overhead ? "Paid by the plan" : p.costSource === "ledger" ? "Cost from the gateway, not Cloudflare" : p.bucket}
256 </span>
257 </td>
258 <td className="tabular px-4 py-2.5 text-right">{usd(p.valueMicros)}</td>
259 <td className="tabular px-4 py-2.5 text-right">{usd(p.costMicros)}</td>
260 <td className="tabular px-4 py-2.5 text-right text-muted">{usd(p.ownCostMicros)}</td>
261 <td className={`tabular px-4 py-2.5 text-right ${p.marginMicros < 0 && !p.overhead ? "text-danger" : ""}`}>{usd(p.marginMicros)}</td>
262 <td className="px-4 py-2.5 text-right sm:pr-5">
263 {p.overhead ? (
264 <span className="text-faint">—</span>
265 ) : (
266 <Badge tone={tone === "mint" ? "mint" : tone === "warn" ? "warn" : tone === "danger" ? "danger" : "plain"}>{percentLabel(p.marginPercent)}</Badge>
267 )}
268 </td>
269 </tr>
270 );
271 })}
272 </tbody>
273 </table>
274 </div>
275 </Section>
276
277 <Section
278 className="mt-6"
279 id="drift"
280 title="Drift"
281 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)."
282 >
283 {report.drift.length === 0 ? (
284 <p className="text-sm text-muted">Nothing has drifted.</p>
285 ) : (
286 <div className="-mx-4 overflow-x-auto sm:-mx-5">
287 <table className="w-full min-w-[44rem] text-sm">
288 <thead>
289 <tr className="border-b border-line text-left text-xs text-muted">
290 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
291 <th className="px-4 py-2 font-medium">Kind</th>
292 <th className="px-4 py-2 text-right font-medium">g1t</th>
293 <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
294 <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
295 </tr>
296 </thead>
297 <tbody>
298 {report.drift.map((d) => {
299 const money = d.kind !== "count";
300 return (
301 <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
302 <td className="px-4 py-2.5 sm:px-5">
303 {d.title}
304 <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
305 </td>
306 <td className="px-4 py-2.5">
307 <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
308 </td>
309 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
310 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
311 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
312 </tr>
313 );
314 })}
315 </tbody>
316 </table>
317 </div>
318 )}
319 </Section>
320
321 <Section
322 className="mt-6"
323 id="proposals"
324 title="Price proposals"
325 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.`}
326 >
327 {report.proposals.length === 0 ? (
328 <p className="text-sm text-muted">No proposals yet.</p>
329 ) : (
330 <ul className="space-y-3">
331 {report.proposals.map((p) => (
332 <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
333 ))}
334 </ul>
335 )}
336 </Section>
337
338 <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.">
339 <div className="-mx-4 overflow-x-auto sm:-mx-5">
340 <table className="w-full min-w-[44rem] text-sm">
341 <thead>
342 <tr className="border-b border-line text-left text-xs text-muted">
343 <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
344 <th className="px-4 py-2 text-right font-medium">Cost</th>
345 <th className="px-4 py-2 text-right font-medium">Price</th>
346 <th className="px-4 py-2 font-medium">From</th>
347 <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
348 </tr>
349 </thead>
350 <tbody>
351 {report.versions.map((v) => (
352 <tr key={v.id} className="border-b border-line align-top last:border-0">
353 <td className="px-4 py-2.5 sm:px-5">
354 <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
355 {!v.appliedAt && (
356 <span className="ml-2">
357 <Badge tone="info">Coming</Badge>
358 </span>
359 )}
360 </td>
361 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
362 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
363 <td className="px-4 py-2.5 text-xs text-muted">
364 <When at={v.effectiveAt} />
365 </td>
366 <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
367 {v.reason} · {v.createdBy}
368 </td>
369 </tr>
370 ))}
371 </tbody>
372 </table>
373 </div>
374 </Section>
375
376 <Section
377 className="mt-6"
378 title="Workspaces that cost most"
379 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."
380 >
381 {report.topWorkspaces.length === 0 ? (
382 <p className="text-sm text-muted">No costs shared out yet.</p>
383 ) : (
384 <div className="-mx-4 overflow-x-auto sm:-mx-5">
385 <table className="w-full min-w-[34rem] text-sm">
386 <thead>
387 <tr className="border-b border-line text-left text-xs text-muted">
388 <th className="px-4 py-2 font-medium sm:px-5">Workspace</th>
389 <th className="px-4 py-2 text-right font-medium">Cost to g1t</th>
390 <th className="px-4 py-2 text-right font-medium">Paid</th>
391 <th className="px-4 py-2 text-right font-medium sm:pr-5">Net</th>
392 </tr>
393 </thead>
394 <tbody>
395 {report.topWorkspaces.map((w) => {
396 const net = w.revenueMicros - w.costMicros;
397 return (
398 <tr key={w.workspace} className="border-b border-line last:border-0">
399 <td className="px-4 py-2.5 sm:px-5">
400 <Link to={`/workspaces/${encodeURIComponent(w.workspace)}#billing`} className="font-mono text-fg hover:underline">
401 {w.workspace}
402 </Link>
403 {w.internal && <span className="ml-2 text-xs text-faint">g1t's own</span>}
404 </td>
405 <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td>
406 <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td>
407 <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>
408 </tr>
409 );
410 })}
411 </tbody>
412 </table>
413 </div>
414 )}
415 </Section>
416
417 <Section
418 className="mt-6"
419 title="Cloudflare's lines"
420 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."
421 >
422 {report.lines.length === 0 ? (
423 <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Read the bill now”.</EmptyState>
424 ) : (
425 <div className="-mx-4 overflow-x-auto sm:-mx-5">
426 <table className="w-full min-w-[48rem] text-sm">
427 <thead>
428 <tr className="border-b border-line text-left text-xs text-muted">
429 <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
430 <th className="px-4 py-2 text-right font-medium">Quantity</th>
431 <th className="px-4 py-2 text-right font-medium">Cost</th>
432 <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
433 </tr>
434 </thead>
435 <tbody>
436 {report.lines.map((l) => (
437 <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
438 <td className="px-4 py-2.5 sm:px-5">
439 <span className="font-mono text-xs">
440 {l.product} / {l.meter}
441 </span>
442 <span className="block text-xs text-faint">
443 {l.rawName}
444 {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
445 </span>
446 </td>
447 <td className="tabular px-4 py-2.5 text-right">
448 {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
449 </td>
450 <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
451 <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>
452 </tr>
453 ))}
454 </tbody>
455 </table>
456 </div>
457 )}
458 </Section>
459
460 <Section
461 className="mt-6"
462 id="mappings"
463 title="Mappings"
464 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."
465 >
466 <MappingsTable report={report} />
467 <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
468 <input type="hidden" name="intent" value="mapping" />
469 <Field label="Cloudflare product">
470 <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
471 </Field>
472 <Field label="Meter prefix" hint="Or * for all of it">
473 <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
474 </Field>
475 <Field label="g1t product">
476 <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
477 </Field>
478 <Field label="Drift threshold, %">
479 <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
480 </Field>
481 <Field label="Price meter" hint="Optional">
482 <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
483 </Field>
484 <Field label="Own meter" hint="Optional">
485 <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
486 </Field>
487 <Field label="Note">
488 <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
489 </Field>
490 <div className="flex flex-col justify-end gap-2">
491 <label className="flex items-center gap-2 text-sm text-muted">
492 <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
493 </label>
494 <div className="flex gap-2">
495 <Button type="submit">Save mapping</Button>
496 <Button type="submit" name="remove" value="1" variant="danger">
497 Remove
498 </Button>
499 </div>
500 </div>
501 {failed?.section === "mapping" && (
502 <div className="sm:col-span-2 lg:col-span-4">
503 <Notice tone="error">{failed.error}</Notice>
504 </div>
505 )}
506 </form>
507 </Section>
508
509 <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
510 <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
511 <input type="hidden" name="intent" value="settings" />
512 <Field label="Apply small moves on their own">
513 <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
514 <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
515 </label>
516 </Field>
517 <Field label="Up to, either way, %">
518 <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
519 </Field>
520 <Field label="Notice for a rise, days">
521 <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
522 </Field>
523 <Field label="Margin floor, %">
524 <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
525 </Field>
526 <Field label="Alert after, days in a row">
527 <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
528 </Field>
529 <Field label="Ignore days costing under, $">
530 <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
531 </Field>
532 <Field label="Flag a workspace costing more than its revenue ×">
533 <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
534 </Field>
535 <Field label="And at least, $">
536 <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
537 </Field>
538 <div className="sm:col-span-2 lg:col-span-4">
539 {failed?.section === "settings" && (
540 <div className="mb-3">
541 <Notice tone="error">{failed.error}</Notice>
542 </div>
543 )}
544 <Button type="submit">Save guardrails</Button>
545 </div>
546 </form>
547 </Section>
548 </main>
549 );
550}
551
552function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
553 const rise = p.proposedCostMicros > p.currentCostMicros;
554 const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
555 return (
556 <li className="rounded-md border border-line px-4 py-3">
557 <div className="flex flex-wrap items-baseline justify-between gap-2">
558 <div className="min-w-0">
559 <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
560 </div>
561 <div className="flex items-center gap-2">
562 {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
563 <Badge tone={statusTone}>{p.status}</Badge>
564 </div>
565 </div>
566 <p className="tabular mt-1.5 text-sm">
567 Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
568 <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
569 </p>
570 <p className="mt-1 text-xs text-muted">{p.reason}</p>
571 <p className="mt-1 text-xs text-faint">
572 From the {p.source}, <When at={p.createdAt} time />
573 {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
574 {p.effectiveAt ? (
575 <>
576 {" "}
577 · in force from <When at={p.effectiveAt} />
578 </>
579 ) : null}
580 {p.note ? ` · “${p.note}”` : ""}
581 </p>
582 {p.status === "open" && (
583 <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
584 <input type="hidden" name="intent" value="decide" />
585 <input type="hidden" name="id" value={p.id} />
586 <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
587 <div className="flex gap-2">
588 <Button type="submit" name="decision" value="approve" variant="lavender">
589 Approve
590 </Button>
591 <Button type="submit" name="decision" value="reject" variant="quiet">
592 Reject
593 </Button>
594 </div>
595 </form>
596 )}
597 {error && (
598 <div className="mt-2">
599 <Notice tone="error">{error}</Notice>
600 </div>
601 )}
602 </li>
603 );
604}
605
606function MappingsTable({ report }: { report: CostsReport }) {
607 return (
608 <div className="-mx-4 overflow-x-auto sm:-mx-5">
609 <table className="w-full min-w-[44rem] text-sm">
610 <thead>
611 <tr className="border-b border-line text-left text-xs text-muted">
612 <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
613 <th className="px-4 py-2 font-medium">g1t product</th>
614 <th className="px-4 py-2 font-medium">Price meter</th>
615 <th className="px-4 py-2 font-medium">Own meter</th>
616 <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
617 </tr>
618 </thead>
619 <tbody>
620 {report.mappings.map((m) => (
621 <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
622 <td className="px-4 py-2 sm:px-5">
623 <span className="font-mono text-xs">
624 {m.product} / {m.meter}
625 </span>
626 {m.note && <span className="block text-xs text-faint">{m.note}</span>}
627 </td>
628 <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
629 <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
630 <td className="px-4 py-2 font-mono text-xs text-muted">
631 {m.ownMeter ?? "—"}
632 {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
633 </td>
634 <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
635 </tr>
636 ))}
637 </tbody>
638 </table>
639 </div>
640 );
641}