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

641 lines31,594 bytesCodeBlame
1import { 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}