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

363 lines18,695 bytesCodeBlame
1import { type ReactNode } from "react";
2
3import type { CostsReport, PriceProposal } from "@g1t/contracts";
4
5import type { Route } from "./+types/costs-bill";
6import { CostsHeader } from "~/components/costs-header";
7import { Badge, Button, EmptyState, Field, Input, Notice, Section, When } from "~/components/ui";
8import { countLabel, driftLabel, percentLabel, unitDollars } from "~/lib/costs";
9import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server";
10import { dollarsField, usd } from "~/lib/money";
11
12export const meta: Route.MetaFunction = () => [{ title: "Bill & pricing · sudo" }, { name: "robots", content: "noindex, nofollow" }];
13
14export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context);
15export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context);
16
17/**
18 * Cloudflare's bill as read, what does not add up against it, and the
19 * price book: proposals, versions, mappings and the guardrails on them.
20 */
21export default function CostsBill({ loaderData, actionData }: Route.ComponentProps) {
22 const { range, report, error, done } = loaderData;
23 const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null;
24 const description =
25 "Cloudflare's bill as read, line by line, what does not add up against g1t's own counts and price book, and the prices themselves: proposals waiting for a decision, every version, the mappings and the guardrails.";
26 if (!report) {
27 return (
28 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
29 <CostsHeader page="bill" range={range} report={null} done={null} runError={null} description={description} />
30 <div className="mt-5">
31 <Notice tone="warn">Billing did not answer for costs: {error}</Notice>
32 </div>
33 </main>
34 );
35 }
36 return (
37 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
38 <CostsHeader page="bill" range={range} report={report} done={done} runError={failed?.section === "run" ? failed.error : null} description={description} />
39
40 <Section
41 className="mt-5"
42 id="drift"
43 title="Drift"
44 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)."
45 >
46 {report.drift.length === 0 ? (
47 <p className="text-sm text-muted">Nothing has drifted.</p>
48 ) : (
49 <div className="-mx-4 overflow-x-auto sm:-mx-5">
50 <table className="w-full min-w-[44rem] text-sm">
51 <thead>
52 <tr className="border-b border-line text-left text-xs text-muted">
53 <th className="px-4 py-2 font-medium sm:px-5">Product</th>
54 <th className="px-4 py-2 font-medium">Kind</th>
55 <th className="px-4 py-2 text-right font-medium">g1t</th>
56 <th className="px-4 py-2 text-right font-medium">Cloudflare</th>
57 <th className="px-4 py-2 text-right font-medium sm:pr-5">Delta</th>
58 </tr>
59 </thead>
60 <tbody>
61 {report.drift.map((d) => {
62 const money = d.kind !== "count";
63 return (
64 <tr key={`${d.bucket}-${d.kind}`} className="border-b border-line align-top last:border-0">
65 <td className="px-4 py-2.5 sm:px-5">
66 {d.title}
67 <span className="mt-0.5 block max-w-md text-xs text-faint">{d.detail.replace(`${d.title}: `, "")}</span>
68 </td>
69 <td className="px-4 py-2.5">
70 <Badge tone={d.kind === "leak" ? "danger" : "warn"}>{driftLabel(d.kind)}</Badge>
71 </td>
72 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.ours) : countLabel(d.ours)}</td>
73 <td className="tabular px-4 py-2.5 text-right">{money ? usd(d.cloudflare) : countLabel(d.cloudflare)}</td>
74 <td className="tabular px-4 py-2.5 text-right sm:pr-5">{percentLabel(d.deltaPercent, { signed: true })}</td>
75 </tr>
76 );
77 })}
78 </tbody>
79 </table>
80 </div>
81 )}
82 </Section>
83
84 <Section
85 className="mt-6"
86 id="proposals"
87 title="Price proposals"
88 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.`}
89 >
90 {report.proposals.length === 0 ? (
91 <p className="text-sm text-muted">No proposals yet.</p>
92 ) : (
93 <ul className="space-y-3">
94 {report.proposals.map((p) => (
95 <ProposalRow key={p.id} proposal={p} error={failed?.section === `proposal-${p.id}` ? failed.error : null} />
96 ))}
97 </ul>
98 )}
99 </Section>
100
101 <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.">
102 <div className="-mx-4 overflow-x-auto sm:-mx-5">
103 <table className="w-full min-w-[44rem] text-sm">
104 <thead>
105 <tr className="border-b border-line text-left text-xs text-muted">
106 <th className="px-4 py-2 font-medium sm:px-5">Meter</th>
107 <th className="px-4 py-2 text-right font-medium">Cost</th>
108 <th className="px-4 py-2 text-right font-medium">Price</th>
109 <th className="px-4 py-2 font-medium">From</th>
110 <th className="px-4 py-2 font-medium sm:pr-5">Why</th>
111 </tr>
112 </thead>
113 <tbody>
114 {report.versions.map((v) => (
115 <tr key={v.id} className="border-b border-line align-top last:border-0">
116 <td className="px-4 py-2.5 sm:px-5">
117 <span className="font-mono text-xs">{v.meter}</span> <span className="text-faint">v{v.version}</span>
118 {!v.appliedAt && (
119 <span className="ml-2">
120 <Badge tone="info">Coming</Badge>
121 </span>
122 )}
123 </td>
124 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.costMicros)}</td>
125 <td className="tabular px-4 py-2.5 text-right">{unitDollars(v.priceMicros)}</td>
126 <td className="px-4 py-2.5 text-xs text-muted">
127 <When at={v.effectiveAt} />
128 </td>
129 <td className="px-4 py-2.5 text-xs text-faint sm:pr-5">
130 {v.reason} · {v.createdBy}
131 </td>
132 </tr>
133 ))}
134 </tbody>
135 </table>
136 </div>
137 </Section>
138
139 <Section
140 className="mt-6"
141 title="Cloudflare's lines"
142 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."
143 >
144 {report.lines.length === 0 ? (
145 <EmptyState title="No lines yet">The bill is read once a day at 04:17 UTC, or now with “Run the analysis now”.</EmptyState>
146 ) : (
147 <div className="-mx-4 overflow-x-auto sm:-mx-5">
148 <table className="w-full min-w-[48rem] text-sm">
149 <thead>
150 <tr className="border-b border-line text-left text-xs text-muted">
151 <th className="px-4 py-2 font-medium sm:px-5">Product / meter</th>
152 <th className="px-4 py-2 text-right font-medium">Quantity</th>
153 <th className="px-4 py-2 text-right font-medium">Cost</th>
154 <th className="px-4 py-2 font-medium sm:pr-5">Cost of</th>
155 </tr>
156 </thead>
157 <tbody>
158 {report.lines.map((l) => (
159 <tr key={`${l.source}-${l.product}-${l.meter}`} className="border-b border-line align-top last:border-0">
160 <td className="px-4 py-2.5 sm:px-5">
161 <span className="font-mono text-xs">
162 {l.product} / {l.meter}
163 </span>
164 <span className="block text-xs text-faint">
165 {l.rawName}
166 {l.source === "artifacts_events" ? " (Artifacts' own count)" : ""}
167 </span>
168 </td>
169 <td className="tabular px-4 py-2.5 text-right">
170 {countLabel(l.quantity)} <span className="text-xs text-faint">{l.unit}</span>
171 </td>
172 <td className="tabular px-4 py-2.5 text-right">{usd(l.costMicros)}</td>
173 <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>
174 </tr>
175 ))}
176 </tbody>
177 </table>
178 </div>
179 )}
180 </Section>
181
182 <Section
183 className="mt-6"
184 id="mappings"
185 title="Mappings"
186 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."
187 >
188 <MappingsTable report={report} />
189 <form method="post" action="#mappings" className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
190 <input type="hidden" name="intent" value="mapping" />
191 <Field label="Cloudflare product">
192 <Input name="product" placeholder="artifacts" defaultValue={failed?.section === "mapping" ? failed.values?.product : ""} required />
193 </Field>
194 <Field label="Meter prefix" hint="Or * for all of it">
195 <Input name="meter" placeholder="*" defaultValue={failed?.section === "mapping" ? failed.values?.meter : ""} />
196 </Field>
197 <Field label="g1t product">
198 <Input name="bucket" placeholder="git" defaultValue={failed?.section === "mapping" ? failed.values?.bucket : ""} />
199 </Field>
200 <Field label="Drift threshold, %">
201 <Input name="driftPercent" inputMode="decimal" placeholder="10" defaultValue={failed?.section === "mapping" ? failed.values?.driftPercent : ""} />
202 </Field>
203 <Field label="Price meter" hint="Optional">
204 <Input name="priceMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.priceMeter : ""} />
205 </Field>
206 <Field label="Own meter" hint="Optional">
207 <Input name="ownMeter" placeholder="git_operations" defaultValue={failed?.section === "mapping" ? failed.values?.ownMeter : ""} />
208 </Field>
209 <Field label="Note">
210 <Input name="note" defaultValue={failed?.section === "mapping" ? failed.values?.note : ""} />
211 </Field>
212 <div className="flex flex-col justify-end gap-2">
213 <label className="flex items-center gap-2 text-sm text-muted">
214 <input type="checkbox" name="scaleToOwn" className="accent-[var(--g1t-merged)]" /> Scale to g1t's count
215 </label>
216 <div className="flex gap-2">
217 <Button type="submit">Save mapping</Button>
218 <Button type="submit" name="remove" value="1" variant="danger">
219 Remove
220 </Button>
221 </div>
222 </div>
223 {failed?.section === "mapping" && (
224 <div className="sm:col-span-2 lg:col-span-4">
225 <Notice tone="error">{failed.error}</Notice>
226 </div>
227 )}
228 </form>
229 </Section>
230
231 <Section className="mt-6" id="guardrails" title="Guardrails" description="What may change prices without a person, and when staff hear about margin.">
232 <form method="post" action="#guardrails" className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
233 <input type="hidden" name="intent" value="settings" />
234 <Field label="Apply small moves on their own">
235 <label className="flex h-[38px] items-center gap-2 text-sm text-fg-soft">
236 <input type="checkbox" name="autoApply" defaultChecked={report.settings.autoApply} className="accent-[var(--g1t-merged)]" /> On
237 </label>
238 </Field>
239 <Field label="Up to, either way, %">
240 <Input name="autoApplyPercent" inputMode="decimal" defaultValue={String(report.settings.autoApplyPercent)} />
241 </Field>
242 <Field label="Notice for a rise, days">
243 <Input name="noticeDays" inputMode="numeric" defaultValue={String(report.settings.noticeDays)} />
244 </Field>
245 <Field label="Margin floor, %">
246 <Input name="marginFloorPercent" inputMode="decimal" defaultValue={String(report.settings.marginFloorPercent)} />
247 </Field>
248 <Field label="Alert after, days in a row">
249 <Input name="alertDays" inputMode="numeric" defaultValue={String(report.settings.alertDays)} />
250 </Field>
251 <Field label="Ignore days costing under, $">
252 <Input name="minDailyCost" inputMode="decimal" defaultValue={dollarsField(report.settings.minDailyCostMicros)} />
253 </Field>
254 <Field label="Flag a workspace costing more than its revenue ×">
255 <Input name="anomalyFactor" inputMode="decimal" defaultValue={String(report.settings.anomalyFactor)} />
256 </Field>
257 <Field label="And at least, $">
258 <Input name="anomalyFloor" inputMode="decimal" defaultValue={dollarsField(report.settings.anomalyFloorMicros)} />
259 </Field>
260 <div className="sm:col-span-2 lg:col-span-4">
261 {failed?.section === "settings" && (
262 <div className="mb-3">
263 <Notice tone="error">{failed.error}</Notice>
264 </div>
265 )}
266 <Button type="submit">Save guardrails</Button>
267 </div>
268 </form>
269 </Section>
270 </main>
271 );
272}
273
274function ProposalRow({ proposal: p, error }: { proposal: PriceProposal; error: string | null }) {
275 const rise = p.proposedCostMicros > p.currentCostMicros;
276 const statusTone = p.status === "open" ? "warn" : p.status === "rejected" || p.status === "superseded" ? "plain" : "mint";
277 return (
278 <li className="rounded-md border border-line px-4 py-3">
279 <div className="flex flex-wrap items-baseline justify-between gap-2">
280 <div className="min-w-0">
281 <span className="font-medium">{p.title}</span> <span className="font-mono text-xs text-faint">{p.meter}</span>
282 </div>
283 <div className="flex items-center gap-2">
284 {p.suspect && <Badge tone="danger">Far off: look first</Badge>}
285 <Badge tone={statusTone}>{p.status}</Badge>
286 </div>
287 </div>
288 <p className="tabular mt-1.5 text-sm">
289 Cost {unitDollars(p.currentCostMicros)} → <span className={rise ? "text-warn" : "text-accent"}>{unitDollars(p.proposedCostMicros)}</span> per {p.unit || "unit"}{" "}
290 <span className="text-faint">({percentLabel(p.changePercent, { signed: true })})</span>
291 </p>
292 <p className="mt-1 text-xs text-muted">{p.reason}</p>
293 <p className="mt-1 text-xs text-faint">
294 From the {p.source}, <When at={p.createdAt} time />
295 {p.decidedBy ? ` · ${p.status} by ${p.decidedBy}` : ""}
296 {p.effectiveAt ? (
297 <>
298 {" "}
299 · in force from <When at={p.effectiveAt} />
300 </>
301 ) : null}
302 {p.note ? ` · “${p.note}”` : ""}
303 </p>
304 {p.status === "open" && (
305 <form method="post" action="#proposals" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center">
306 <input type="hidden" name="intent" value="decide" />
307 <input type="hidden" name="id" value={p.id} />
308 <Input name="note" placeholder="A note (needed to reject)" className="sm:max-w-sm" />
309 <div className="flex gap-2">
310 <Button type="submit" name="decision" value="approve" variant="lavender">
311 Approve
312 </Button>
313 <Button type="submit" name="decision" value="reject" variant="quiet">
314 Reject
315 </Button>
316 </div>
317 </form>
318 )}
319 {error && (
320 <div className="mt-2">
321 <Notice tone="error">{error}</Notice>
322 </div>
323 )}
324 </li>
325 );
326}
327
328function MappingsTable({ report }: { report: CostsReport }) {
329 return (
330 <div className="-mx-4 overflow-x-auto sm:-mx-5">
331 <table className="w-full min-w-[44rem] text-sm">
332 <thead>
333 <tr className="border-b border-line text-left text-xs text-muted">
334 <th className="px-4 py-2 font-medium sm:px-5">Cloudflare</th>
335 <th className="px-4 py-2 font-medium">g1t product</th>
336 <th className="px-4 py-2 font-medium">Price meter</th>
337 <th className="px-4 py-2 font-medium">Own meter</th>
338 <th className="px-4 py-2 text-right font-medium sm:pr-5">Drift at</th>
339 </tr>
340 </thead>
341 <tbody>
342 {report.mappings.map((m) => (
343 <tr key={`${m.product}-${m.meter}`} className="border-b border-line align-top last:border-0">
344 <td className="px-4 py-2 sm:px-5">
345 <span className="font-mono text-xs">
346 {m.product} / {m.meter}
347 </span>
348 {m.note && <span className="block text-xs text-faint">{m.note}</span>}
349 </td>
350 <td className="px-4 py-2 text-fg-soft">{m.bucket}</td>
351 <td className="px-4 py-2 font-mono text-xs text-muted">{m.priceMeter ?? "—"}</td>
352 <td className="px-4 py-2 font-mono text-xs text-muted">
353 {m.ownMeter ?? "—"}
354 {m.scaleToOwn && <span className="block font-sans text-faint">scaled</span>}
355 </td>
356 <td className="tabular px-4 py-2 text-right text-muted sm:pr-5">{m.driftPercent}%</td>
357 </tr>
358 ))}
359 </tbody>
360 </table>
361 </div>
362 );
363}