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