| 1 | import type { ReactNode } from "react"; |
| 2 | import { Link } from "react-router"; |
| 3 | |
| 4 | import type { CostsReport, PlatformGuard, PlatformMetric } from "@g1t/contracts"; |
| 5 | |
| 6 | import type { Route } from "./+types/costs"; |
| 7 | import { DaysChart } from "~/components/costs"; |
| 8 | import { CostsHeader, chip, costsHref } from "~/components/costs-header"; |
| 9 | import { Badge, Button, Field, Input, Notice, Section, Stat, When } from "~/components/ui"; |
| 10 | import { |
| 11 | PAUSE_LEVELS, |
| 12 | capPercent, |
| 13 | count, |
| 14 | daySeries, |
| 15 | givenParts as givenBy, |
| 16 | marginOnPrice, |
| 17 | marginTone, |
| 18 | parseBucket, |
| 19 | percentLabel, |
| 20 | spendRows, |
| 21 | subscriptionsOver, |
| 22 | thresholdShare, |
| 23 | whoPaid, |
| 24 | } from "~/lib/costs"; |
| 25 | import { type CostsActionResult, costsAction, costsLoader } from "~/lib/costs-route.server"; |
| 26 | import { usd } from "~/lib/money"; |
| 27 | |
| 28 | export const meta: Route.MetaFunction = () => [{ title: "Costs & margin · sudo" }, { name: "robots", content: "noindex, nofollow" }]; |
| 29 | |
| 30 | /** Billing's MARGIN_PERCENT: what is added to cost. */ |
| 31 | const MARKUP_PERCENT = 20; |
| 32 | |
| 33 | export const loader = ({ request, context }: Route.LoaderArgs) => costsLoader(request, context); |
| 34 | export const action = ({ request, context }: Route.ActionArgs) => costsAction(request, context); |
| 35 | |
| 36 | /** |
| 37 | * Where g1t's money goes: the statement, what g1t pays for itself against |
| 38 | * its caps, and the same by day, by product and by workspace. |
| 39 | */ |
| 40 | export default function Costs({ loaderData, actionData }: Route.ComponentProps) { |
| 41 | const { range, report, error, done } = loaderData; |
| 42 | const failed = actionData && "error" in actionData ? (actionData as CostsActionResult) : null; |
| 43 | const description = `What g1t cost to run (Cloudflare's bill) against what workspaces paid, with what g1t gave away on purpose kept apart. Prices are cost plus ${MARKUP_PERCENT}%, a ${percentLabel(marginOnPrice(MARKUP_PERCENT))} margin: every margin here is a share of the price, not of the cost. The bill itself, drift and the price book are on Bill & pricing.`; |
| 44 | if (!report) { |
| 45 | return ( |
| 46 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 47 | <CostsHeader page="costs" range={range} report={null} done={null} runError={null} description={description} /> |
| 48 | <div className="mt-5"> |
| 49 | <Notice tone="warn">Billing did not answer for costs: {error}</Notice> |
| 50 | </div> |
| 51 | <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} /> |
| 52 | </main> |
| 53 | ); |
| 54 | } |
| 55 | const bucket = parseBucket( |
| 56 | loaderData.bucket, |
| 57 | report.products.map((p) => p.bucket), |
| 58 | ); |
| 59 | const product = report.products.find((p) => p.bucket === bucket) ?? null; |
| 60 | const series = daySeries(report.days, report.since, report.until, bucket); |
| 61 | const floor = report.settings.marginFloorPercent; |
| 62 | const open = report.proposals.filter((p) => p.status === "open"); |
| 63 | return ( |
| 64 | <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10"> |
| 65 | <CostsHeader |
| 66 | page="costs" |
| 67 | range={range} |
| 68 | report={report} |
| 69 | done={done} |
| 70 | runError={failed?.section === "run" ? failed.error : null} |
| 71 | description={description} |
| 72 | /> |
| 73 | |
| 74 | <Statement report={report} floor={floor} range={range} proposals={open.length} /> |
| 75 | |
| 76 | <SpendSection caps={report.caps} range={range} error={failed?.section === "lift" ? failed.error : null} /> |
| 77 | |
| 78 | <PlatformSection guard={loaderData.guard} unavailable={loaderData.guardError} failed={failed} /> |
| 79 | |
| 80 | <Section |
| 81 | className="mt-6" |
| 82 | title={product ? `${product.title}, by day` : "By day"} |
| 83 | description={ |
| 84 | product |
| 85 | ? "What customers were charged for it at price, before included usage and pools paid part, against what it cost." |
| 86 | : "Money in (usage paid for and the plan) against every cost. Pick a product to see its own." |
| 87 | } |
| 88 | > |
| 89 | <nav aria-label="Product" className="mb-4 flex flex-wrap gap-2"> |
| 90 | <Link to={costsHref("costs", range, null)} aria-current={!bucket ? "page" : undefined} className={chip(!bucket)}> |
| 91 | All of g1t |
| 92 | </Link> |
| 93 | {report.products.map((p) => ( |
| 94 | <Link |
| 95 | key={p.bucket} |
| 96 | to={costsHref("costs", range, p.bucket)} |
| 97 | aria-current={p.bucket === bucket ? "page" : undefined} |
| 98 | className={chip(p.bucket === bucket)} |
| 99 | > |
| 100 | {p.title} |
| 101 | </Link> |
| 102 | ))} |
| 103 | </nav> |
| 104 | <DaysChart |
| 105 | days={series} |
| 106 | floor={floor} |
| 107 | revenueLabel={product ? "Charged at price" : "Money in"} |
| 108 | label={product ? `${product.title}: charged against Cloudflare cost by day` : "Money in against Cloudflare cost by day"} |
| 109 | /> |
| 110 | </Section> |
| 111 | |
| 112 | <Section |
| 113 | className="mt-6" |
| 114 | title="By product" |
| 115 | description={`Each of g1t's products over the last ${range} days: what customers were charged at price, given away or not, against what it cost. The margin is the charge less the cost, as a share of the charge; at cost plus ${MARKUP_PERCENT}% it is ${percentLabel(marginOnPrice(MARKUP_PERCENT))}. The floor is ${floor}%.`} |
| 116 | > |
| 117 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> |
| 118 | <table className="w-full min-w-176 text-sm"> |
| 119 | <thead> |
| 120 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 121 | <th className="px-4 py-2 font-medium sm:px-5">Product</th> |
| 122 | <th className="px-4 py-2 text-right font-medium">Charged at price</th> |
| 123 | <th className="px-4 py-2 text-right font-medium">Cost</th> |
| 124 | <th className="px-4 py-2 text-right font-medium">Price book said</th> |
| 125 | <th className="px-4 py-2 text-right font-medium">Margin</th> |
| 126 | <th className="px-4 py-2 text-right font-medium sm:pr-5">% of price</th> |
| 127 | </tr> |
| 128 | </thead> |
| 129 | <tbody> |
| 130 | {report.products.map((p) => { |
| 131 | const tone = p.overhead ? undefined : marginTone(p.marginPercent, floor); |
| 132 | return ( |
| 133 | <tr key={p.bucket} className="border-b border-line last:border-0"> |
| 134 | <td className="px-4 py-2.5 sm:px-5"> |
| 135 | <Link to={costsHref("costs", range, p.bucket)} className="text-fg hover:underline"> |
| 136 | {p.title} |
| 137 | </Link> |
| 138 | <span className="block text-xs text-faint"> |
| 139 | {p.overhead ? "Paid by the plan" : p.costSource === "ledger" ? "Cost from the gateway, not Cloudflare" : p.bucket} |
| 140 | </span> |
| 141 | </td> |
| 142 | <td className="tabular px-4 py-2.5 text-right">{usd(p.valueMicros)}</td> |
| 143 | <td className="tabular px-4 py-2.5 text-right">{usd(p.costMicros)}</td> |
| 144 | <td className="tabular px-4 py-2.5 text-right text-muted">{usd(p.ownCostMicros)}</td> |
| 145 | <td className={`tabular px-4 py-2.5 text-right ${p.marginMicros < 0 && !p.overhead ? "text-danger" : ""}`}>{usd(p.marginMicros)}</td> |
| 146 | <td className="px-4 py-2.5 text-right sm:pr-5"> |
| 147 | {p.overhead ? ( |
| 148 | <span className="text-faint">—</span> |
| 149 | ) : ( |
| 150 | <Badge tone={tone === "mint" ? "mint" : tone === "warn" ? "warn" : tone === "danger" ? "danger" : "plain"}> |
| 151 | {percentLabel(p.marginPercent)} |
| 152 | </Badge> |
| 153 | )} |
| 154 | </td> |
| 155 | </tr> |
| 156 | ); |
| 157 | })} |
| 158 | </tbody> |
| 159 | </table> |
| 160 | </div> |
| 161 | </Section> |
| 162 | |
| 163 | <Section |
| 164 | className="mt-6" |
| 165 | title="Workspaces that cost most" |
| 166 | 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." |
| 167 | > |
| 168 | {report.topWorkspaces.length === 0 ? ( |
| 169 | <p className="text-sm text-muted">No costs shared out yet.</p> |
| 170 | ) : ( |
| 171 | <div className="-mx-4 overflow-x-auto sm:-mx-5"> |
| 172 | <table className="w-full min-w-136 text-sm"> |
| 173 | <thead> |
| 174 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 175 | <th className="px-4 py-2 font-medium sm:px-5">Workspace</th> |
| 176 | <th className="px-4 py-2 text-right font-medium">Cost to g1t</th> |
| 177 | <th className="px-4 py-2 text-right font-medium">Given away</th> |
| 178 | <th className="px-4 py-2 text-right font-medium">Paid</th> |
| 179 | <th className="px-4 py-2 text-right font-medium sm:pr-5">Net</th> |
| 180 | </tr> |
| 181 | </thead> |
| 182 | <tbody> |
| 183 | {report.topWorkspaces.map((w) => { |
| 184 | const net = w.revenueMicros - w.costMicros; |
| 185 | // What was given away on purpose is a budget, not a loss: |
| 186 | // red only for what neither paid nor was given. |
| 187 | const lost = net + (w.givenMicros ?? 0) < 0; |
| 188 | return ( |
| 189 | <tr key={w.workspace} className="border-b border-line last:border-0"> |
| 190 | <td className="px-4 py-2.5 sm:px-5"> |
| 191 | <Link to={`/workspaces/${encodeURIComponent(w.workspace)}#billing`} className="font-mono text-fg hover:underline"> |
| 192 | {w.workspace} |
| 193 | </Link> |
| 194 | {w.internal && <span className="ml-2 text-xs text-faint">g1t's own</span>} |
| 195 | </td> |
| 196 | <td className="tabular px-4 py-2.5 text-right">{usd(w.costMicros)}</td> |
| 197 | <td className="tabular px-4 py-2.5 text-right text-fg-soft">{usd(w.givenMicros ?? 0)}</td> |
| 198 | <td className="tabular px-4 py-2.5 text-right">{usd(w.revenueMicros)}</td> |
| 199 | <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${lost && !w.internal ? "text-danger" : "text-fg-soft"}`}>{usd(net)}</td> |
| 200 | </tr> |
| 201 | ); |
| 202 | })} |
| 203 | </tbody> |
| 204 | </table> |
| 205 | </div> |
| 206 | )} |
| 207 | {(report.unattributedMicros ?? 0) > 0 && ( |
| 208 | <p className="mt-3 text-xs text-muted"> |
| 209 | And {usd(report.unattributedMicros ?? 0, { cents: true })} of running g1t on days no workspace used anything: no one's, so not above. The |
| 210 | workspaces' costs and this add up to the cost in the statement. |
| 211 | </p> |
| 212 | )} |
| 213 | </Section> |
| 214 | </main> |
| 215 | ); |
| 216 | } |
| 217 | |
| 218 | function CapMeter({ label, used, cap, hint }: { label: string; used: number; cap: number; hint: ReactNode }) { |
| 219 | const percent = capPercent(used, cap); |
| 220 | const tone = cap > 0 && used >= cap ? "fill-danger" : percent >= 75 ? "fill-warn" : "fill-accent"; |
| 221 | return ( |
| 222 | <div className="rounded-md border border-line px-4 py-3"> |
| 223 | <div className="flex flex-wrap items-baseline justify-between gap-2"> |
| 224 | <span className="text-sm font-medium">{label}</span> |
| 225 | <span className="tabular text-sm"> |
| 226 | {usd(used)} <span className="text-faint">of {cap > 0 ? usd(cap) : "no cap"}</span> |
| 227 | </span> |
| 228 | </div> |
| 229 | {/* Drawn as SVG, its width an attribute: the page's style policy blocks a style attribute. */} |
| 230 | <svg |
| 231 | viewBox="0 0 100 4" |
| 232 | preserveAspectRatio="none" |
| 233 | className="mt-2 block h-1.5 w-full" |
| 234 | role="meter" |
| 235 | aria-valuemin={0} |
| 236 | aria-valuemax={100} |
| 237 | aria-valuenow={Math.round(percent)} |
| 238 | aria-label={label} |
| 239 | > |
| 240 | <rect x="0" y="0" width="100" height="4" rx="2" className="fill-line" /> |
| 241 | {percent > 0 && <rect x="0" y="0" width={Math.max(2, percent)} height="4" rx="2" className={tone} />} |
| 242 | </svg> |
| 243 | <p className="mt-1.5 text-xs text-muted">{hint}</p> |
| 244 | </div> |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | /** |
| 249 | * Where the money went, as a short statement: usage sold and running g1t |
| 250 | * (each against what paid for it), what g1t gave away on purpose (a budget, |
| 251 | * never a loss), and who was paid. |
| 252 | */ |
| 253 | function Statement({ report, floor, range, proposals }: { report: CostsReport; floor: number; range: number; proposals: number }) { |
| 254 | const o = report.overall; |
| 255 | // Reports from before the statement have only the totals. |
| 256 | const given = o.givenMicros ?? 0; |
| 257 | const usageCost = o.usageCostMicros ?? o.costMicros - given; |
| 258 | const included = o.includedMicros ?? 0; |
| 259 | const usageIn = o.usageMicros + included; |
| 260 | const usageMargin = o.usageMarginMicros ?? usageIn - usageCost; |
| 261 | // Under a cent sold, a percentage says nothing (a few micros against none). |
| 262 | const soldSomething = usageIn >= 10_000; |
| 263 | const usagePercent = soldSomething && o.usageMarginPercent !== undefined ? o.usageMarginPercent : null; |
| 264 | const running = o.runningCostMicros ?? 0; |
| 265 | const unmapped = o.unmappedCostMicros ?? 0; |
| 266 | // Cloudflare's subscriptions are not on the usage bill: each day's share of its billing cycle. |
| 267 | const subscriptions = subscriptionsOver(report.caps.fixedMonthlyMicros, range, o); |
| 268 | const paid = whoPaid(o, subscriptions); |
| 269 | const moneyIn = o.usageMicros + o.plansMicros; |
| 270 | const spent = paid.totalMicros; |
| 271 | const net = moneyIn - spent; |
| 272 | const givenParts = givenBy(o); |
| 273 | const rows: { title: string; note: string; in: number | null; cost: number; result: number | null; tone?: "danger" | "warn" | "muted" }[] = [ |
| 274 | { |
| 275 | title: "Usage sold", |
| 276 | note: |
| 277 | included > 0 |
| 278 | ? `What workspaces paid for usage (${usd(o.usageMicros, { cents: true })}) and their plan's included usage (${usd(included, { cents: true })}), against what it cost` |
| 279 | : "What workspaces paid for usage, against what that usage cost", |
| 280 | in: usageIn, |
| 281 | cost: usageCost, |
| 282 | result: usageMargin, |
| 283 | tone: usageMargin < 0 ? "danger" : undefined, |
| 284 | }, |
| 285 | { |
| 286 | title: "Running g1t", |
| 287 | note: |
| 288 | included > 0 |
| 289 | ? `Plans (${usd(o.plansMicros, { cents: true })}) less the included usage they paid for, against Workers, D1, KV, Queues and the rest` |
| 290 | : "Plans, against Workers, D1, KV, Queues and the rest of the platform", |
| 291 | in: o.plansMicros - included, |
| 292 | cost: running, |
| 293 | result: o.plansMicros - included - running, |
| 294 | tone: o.plansMicros - included - running < 0 ? "warn" : undefined, |
| 295 | }, |
| 296 | { |
| 297 | title: "Cloudflare subscriptions", |
| 298 | note: |
| 299 | report.caps.fixedSource === "cloudflare" |
| 300 | ? `Fixed, as Cloudflare lists them${(report.caps.fixedItems ?? []).length > 0 ? ` (${(report.caps.fixedItems ?? []).map((i) => i.name).join(", ")})` : ""}; not on the usage bill. Each day is its billing cycle's share of ${usd(report.caps.fixedMonthlyMicros, { cents: true })} a month` |
| 301 | : "Fixed, an estimate (CLOUDFLARE_FIXED_MONTHLY_MICROS) until Cloudflare's list is read; not on the usage bill", |
| 302 | in: null, |
| 303 | cost: subscriptions, |
| 304 | result: -subscriptions, |
| 305 | tone: "muted", |
| 306 | }, |
| 307 | ...(unmapped > 0 |
| 308 | ? [ |
| 309 | { |
| 310 | title: "Not mapped", |
| 311 | note: "Billed by Cloudflare, charged for by nothing yet", |
| 312 | in: null, |
| 313 | cost: unmapped, |
| 314 | result: -unmapped, |
| 315 | tone: "danger" as const, |
| 316 | }, |
| 317 | ] |
| 318 | : []), |
| 319 | { |
| 320 | title: "Given away", |
| 321 | note: givenParts.length > 0 ? givenParts.map(([why, micros]) => `${why} ${usd(micros, { cents: true })}`).join(" · ") : "Nothing this range", |
| 322 | in: null, |
| 323 | cost: given, |
| 324 | result: null, |
| 325 | tone: "muted", |
| 326 | }, |
| 327 | ]; |
| 328 | const resultClass = (tone?: string) => (tone === "danger" ? "text-danger" : tone === "warn" ? "text-warn" : tone === "muted" ? "text-muted" : "text-fg"); |
| 329 | return ( |
| 330 | <> |
| 331 | <div className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4"> |
| 332 | <Stat |
| 333 | label="Margin on usage sold" |
| 334 | value={percentLabel(usagePercent)} |
| 335 | hint={ |
| 336 | soldSomething |
| 337 | ? `Of the price: ${usd(usageIn, { cents: true })} paid for usage that cost ${usd(usageCost, { cents: true })}` |
| 338 | : "No usage sold in this range" |
| 339 | } |
| 340 | tone={marginTone(usagePercent, floor)} |
| 341 | /> |
| 342 | <Stat |
| 343 | label="Given away on purpose" |
| 344 | value={usd(given, { cents: true })} |
| 345 | hint={givenParts.length > 0 ? givenParts.map(([why, micros]) => `${usd(micros, { cents: true })} ${why}`).join(", ") : "Nothing given this range"} |
| 346 | /> |
| 347 | <Stat |
| 348 | label="Who g1t paid" |
| 349 | value={usd(paid.totalMicros, { cents: true })} |
| 350 | hint={`Cloudflare's usage ${usd(paid.cloudflareMicros, { cents: true })} and subscriptions ${usd(paid.subscriptionsMicros, { cents: true })}${paid.modelsMicros > 0 ? `, model providers ${usd(paid.modelsMicros, { cents: true })} (from before agents were retired)` : ""}; the cost in All in`} |
| 351 | /> |
| 352 | <Stat |
| 353 | label="Proposals waiting" |
| 354 | value={String(proposals)} |
| 355 | hint={ |
| 356 | <> |
| 357 | <Link to={costsHref("bill", range) + "#proposals"} className="underline underline-offset-2"> |
| 358 | On Bill & pricing |
| 359 | </Link> |
| 360 | {report.fetchedAt ? ( |
| 361 | <> |
| 362 | {" "} |
| 363 | · bill read <When at={report.fetchedAt} time /> |
| 364 | </> |
| 365 | ) : ( |
| 366 | " · the bill has not been read yet" |
| 367 | )} |
| 368 | </> |
| 369 | } |
| 370 | tone={proposals > 0 ? "warn" : undefined} |
| 371 | /> |
| 372 | </div> |
| 373 | <div className="mt-3 overflow-x-auto rounded-lg border border-line bg-surface"> |
| 374 | <table className="w-full min-w-xl text-sm"> |
| 375 | <thead> |
| 376 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 377 | <th className="px-4 py-2 font-medium sm:px-5"> |
| 378 | {report.since} to {report.until} |
| 379 | </th> |
| 380 | <th className="px-4 py-2 text-right font-medium">Paid in</th> |
| 381 | <th className="px-4 py-2 text-right font-medium">Cost</th> |
| 382 | <th className="px-4 py-2 text-right font-medium sm:pr-5">Result</th> |
| 383 | </tr> |
| 384 | </thead> |
| 385 | <tbody> |
| 386 | {rows.map((row) => ( |
| 387 | <tr key={row.title} className="border-b border-line"> |
| 388 | <td className="px-4 py-2.5 sm:px-5"> |
| 389 | <span className="text-fg">{row.title}</span> |
| 390 | <span className="block text-xs text-faint">{row.note}</span> |
| 391 | </td> |
| 392 | <td className="tabular px-4 py-2.5 text-right">{row.in == null ? <span className="text-faint">—</span> : usd(row.in, { cents: true })}</td> |
| 393 | <td className="tabular px-4 py-2.5 text-right">{usd(row.cost, { cents: true })}</td> |
| 394 | <td className={`tabular px-4 py-2.5 text-right sm:pr-5 ${resultClass(row.tone)}`}> |
| 395 | {row.result == null ? "a budget" : usd(row.result, { signed: true, cents: true })} |
| 396 | </td> |
| 397 | </tr> |
| 398 | ))} |
| 399 | <tr> |
| 400 | <td className="px-4 py-2.5 sm:px-5"> |
| 401 | <span className="font-medium text-fg">All in</span> |
| 402 | <span className="block text-xs text-faint"> |
| 403 | {given > 0 |
| 404 | ? `${usd(given, { cents: true })} of the cost was given away on purpose; without it, ${usd(net + given, { signed: true, cents: true })}` |
| 405 | : "Everything above"} |
| 406 | </span> |
| 407 | </td> |
| 408 | <td className="tabular px-4 py-2.5 text-right font-medium">{usd(moneyIn, { cents: true })}</td> |
| 409 | <td className="tabular px-4 py-2.5 text-right font-medium">{usd(spent, { cents: true })}</td> |
| 410 | <td className="tabular px-4 py-2.5 text-right font-medium text-fg sm:pr-5">{usd(net, { signed: true, cents: true })}</td> |
| 411 | </tr> |
| 412 | </tbody> |
| 413 | </table> |
| 414 | </div> |
| 415 | <p className="mt-2 text-xs text-faint"> |
| 416 | Credits from g1t, {report.since} to {report.until}: {usd(o.creditsGivenMicros ?? 0, { cents: true })} given,{" "} |
| 417 | {usd(o.creditsUsedMicros ?? 0, { cents: true })} spent on usage. What promotional and goodwill credit paid for is given away above, never money in; |
| 418 | refunds took {usd(o.creditsRefundedMicros ?? 0, { cents: true })} off money in on the days they refund.{" "} |
| 419 | <Link to="/credits" className="underline underline-offset-2"> |
| 420 | Every credit |
| 421 | </Link> |
| 422 | </p> |
| 423 | <dl className="mt-3 grid grid-cols-2 gap-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm sm:px-5"> |
| 424 | <div> |
| 425 | <dt className="text-xs text-muted">Tax collected</dt> |
| 426 | <dd className="tabular text-fg">{usd(o.taxCollectedMicros ?? 0, { cents: true })}</dd> |
| 427 | <dd className="text-xs text-faint">Owed to tax authorities; never in money in or margin. Filed from Stripe Tax.</dd> |
| 428 | </div> |
| 429 | <div> |
| 430 | <dt className="text-xs text-muted">Card fees passed on</dt> |
| 431 | <dd className="tabular text-fg">{usd(o.cardFeesMicros ?? 0, { cents: true })}</dd> |
| 432 | <dd className="text-xs text-faint">Only payments from before the fee was folded into prices; not revenue either.</dd> |
| 433 | </div> |
| 434 | </dl> |
| 435 | </> |
| 436 | ); |
| 437 | } |
| 438 | |
| 439 | function SpendSection({ caps, range, error }: { caps: CostsReport["caps"]; range: number; error: string | null }) { |
| 440 | const { rows, totalMicros } = spendRows(caps); |
| 441 | const net = caps.revenueMicros - totalMicros; |
| 442 | return ( |
| 443 | <Section |
| 444 | className="mt-6" |
| 445 | id="spend" |
| 446 | title="g1t's own spend" |
| 447 | description="What g1t pays for itself this calendar month, today included: accounts on a 100% discount, the trial and open-source pools, free workspaces' overruns, and usage charged without real money behind it. Each is what the work cost g1t, never its price, counted as each charge settled: the price book's cost of sandboxes and builds. Two caps hold it: each 100%-discount account's monthly budget, and a daily breaker on all of it that pauses new compute g1t would pay for: workflows, checks and the merge queue." |
| 448 | > |
| 449 | <div className="grid gap-3 lg:grid-cols-2"> |
| 450 | <CapMeter |
| 451 | label={`Today, ${caps.day} (UTC)`} |
| 452 | used={caps.todayMicros} |
| 453 | cap={caps.dailyCapMicros} |
| 454 | hint={ |
| 455 | caps.tripped ? ( |
| 456 | <span className="text-danger"> |
| 457 | Breaker open |
| 458 | {caps.trippedAt ? ( |
| 459 | <> |
| 460 | {" "} |
| 461 | since <When at={caps.trippedAt} time /> |
| 462 | </> |
| 463 | ) : null} |
| 464 | : new work g1t pays for waits until 00:00 UTC. |
| 465 | </span> |
| 466 | ) : caps.liftedBy ? ( |
| 467 | <> |
| 468 | Lifted for today by {caps.liftedBy} |
| 469 | {caps.liftNote ? `: “${caps.liftNote}”` : ""}. |
| 470 | </> |
| 471 | ) : ( |
| 472 | "Paying workspaces on a live card are never paused. PLATFORM_DAILY_SPEND_CAP_MICROS." |
| 473 | ) |
| 474 | } |
| 475 | /> |
| 476 | {caps.comped.map((b) => ( |
| 477 | <CapMeter |
| 478 | key={b.account} |
| 479 | label={`${b.name}, ${caps.month} (100% discount)`} |
| 480 | used={b.usedMicros} |
| 481 | cap={b.ceilingMicros} |
| 482 | hint={ |
| 483 | <> |
| 484 | {b.ceilingMicros > 0 && b.usedMicros >= b.ceilingMicros ? <span className="text-danger">Used up: new work on it is refused. </span> : null} |
| 485 | {b.defaultCeiling ? "The default budget (COMPED_MONTHLY_CEILING_MICROS)" : "Its own budget, in its terms"}. Raise it on{" "} |
| 486 | <Link to={`/workspaces/${encodeURIComponent(b.name)}#billing`} className="underline underline-offset-2"> |
| 487 | its account |
| 488 | </Link> |
| 489 | : Terms, Limit. |
| 490 | </> |
| 491 | } |
| 492 | /> |
| 493 | ))} |
| 494 | </div> |
| 495 | {(caps.tripped || error) && ( |
| 496 | <form method="post" action="#spend" className="mt-4 flex flex-col gap-2 sm:flex-row sm:items-end"> |
| 497 | <input type="hidden" name="intent" value="lift" /> |
| 498 | <Field label="Why lift it" hint="Recorded in the audit log."> |
| 499 | <Input name="note" required minLength={5} maxLength={500} placeholder="e.g. Launch day; watching it" className="sm:min-w-[24rem]" /> |
| 500 | </Field> |
| 501 | <Button type="submit" variant="danger"> |
| 502 | Lift for today |
| 503 | </Button> |
| 504 | </form> |
| 505 | )} |
| 506 | {error && ( |
| 507 | <div className="mt-3"> |
| 508 | <Notice tone="error">{error}</Notice> |
| 509 | </div> |
| 510 | )} |
| 511 | <div className="-mx-4 mt-5 overflow-x-auto sm:-mx-5"> |
| 512 | <table className="w-full min-w-136 text-sm"> |
| 513 | <thead> |
| 514 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 515 | <th className="px-4 py-2 font-medium sm:px-5">{caps.month}, so far</th> |
| 516 | <th className="px-4 py-2 text-right font-medium sm:pr-5">g1t paid</th> |
| 517 | </tr> |
| 518 | </thead> |
| 519 | <tbody> |
| 520 | {rows.map((row) => ( |
| 521 | <tr key={row.key} className="border-b border-line align-top"> |
| 522 | <td className="px-4 py-2.5 sm:px-5"> |
| 523 | {row.title} |
| 524 | <span className="block text-xs text-faint">{row.note}</span> |
| 525 | </td> |
| 526 | <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(row.micros)}</td> |
| 527 | </tr> |
| 528 | ))} |
| 529 | <tr className="border-b border-line font-medium"> |
| 530 | <td className="px-4 py-2.5 sm:px-5">All of it</td> |
| 531 | <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(totalMicros)}</td> |
| 532 | </tr> |
| 533 | <tr className="border-b border-line"> |
| 534 | <td className="px-4 py-2.5 sm:px-5"> |
| 535 | Money in |
| 536 | <span className="block text-xs text-faint">Usage paid for with real money and the plan, as last reconciled</span> |
| 537 | </td> |
| 538 | <td className="tabular px-4 py-2.5 text-right sm:pr-5">{usd(caps.revenueMicros)}</td> |
| 539 | </tr> |
| 540 | <tr> |
| 541 | <td className="px-4 py-2.5 sm:px-5">Money in less what g1t paid</td> |
| 542 | <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> |
| 543 | </tr> |
| 544 | </tbody> |
| 545 | </table> |
| 546 | </div> |
| 547 | <p className="mt-3 text-xs text-muted"> |
| 548 | Why this differs from the statement above: this is {caps.month} so far, the statement the last {range} days. The statement takes Cloudflare's bill |
| 549 | as the cost of what Cloudflare runs, so sandboxes and builds inside Cloudflare's included usage cost nothing there, and here what the price book |
| 550 | says they cost. Cloudflare's subscriptions are counted the same way in both: each day its billing cycle's share of the month's price. |
| 551 | {(caps.resetMicros ?? 0) > 0 && ( |
| 552 | <> |
| 553 | {" "} |
| 554 | And {usd(caps.resetMicros ?? 0, { cents: true })} of this was spent on {(caps.resetWorkspaces ?? []).join(", ")} before a testing reset wiped |
| 555 | {(caps.resetWorkspaces ?? []).length === 1 ? " its" : " their"} billing: still g1t's spend, but the statement has it only where the reset kept |
| 556 | it, as given away. |
| 557 | </> |
| 558 | )} |
| 559 | </p> |
| 560 | </Section> |
| 561 | ); |
| 562 | } |
| 563 | |
| 564 | /** |
| 565 | * The platform pause and the hourly usage watch (billing's platform.rs; |
| 566 | * docs.g1t.sh/guides/deploy-to-cloudflare/#spend-guardrails): four levels |
| 567 | * staff can pause across g1t, what Cloudflare counted in the last hour |
| 568 | * against each threshold, and the last day's breaches. |
| 569 | */ |
| 570 | function PlatformSection({ guard, unavailable, failed }: { guard: PlatformGuard | null; unavailable: string | null; failed: CostsActionResult | null }) { |
| 571 | return ( |
| 572 | <Section |
| 573 | className="mt-6" |
| 574 | id="platform" |
| 575 | title="Platform pause" |
| 576 | description="What Cloudflare counted for all of g1t, read at a quarter past each hour: Workers, D1, Queues, Durable Objects, KV and Artifacts, each against an hourly threshold (PLATFORM_HOURLY_* in billing's wrangler.jsonc). A breach emails staff once per metric every 6 hours. Five times a threshold pauses what that metric feeds, of the levels AUTO_PAUSE names. Any level can be paused here by hand; every service sees a change within 30 seconds." |
| 577 | > |
| 578 | {!guard ? ( |
| 579 | <Notice tone="warn">Billing did not answer for the platform pause: {unavailable}</Notice> |
| 580 | ) : ( |
| 581 | <> |
| 582 | {!guard.can_read && ( |
| 583 | <div className="mb-4"> |
| 584 | <Notice tone="warn"> |
| 585 | Billing has no Cloudflare token with Account Analytics Read, so the hourly watch reads nothing. The pause still works. |
| 586 | </Notice> |
| 587 | </div> |
| 588 | )} |
| 589 | {(guard.blind ?? []).length > 0 && ( |
| 590 | <div className="mb-4"> |
| 591 | <Notice tone="warn"> |
| 592 | The watcher can't see: {(guard.blind ?? []).map((b) => b.key).join(", ")} |
| 593 | {guard.last_run ? ` (the run for ${guard.last_run})` : ""}. Their metrics are not watched until it is fixed; three runs in a row email staff. |
| 594 | <ul className="mt-2 space-y-1 text-xs"> |
| 595 | {(guard.blind ?? []).map((b) => ( |
| 596 | <li key={b.key}> |
| 597 | <code>{b.dataset}</code> ({b.key}): {b.error} |
| 598 | </li> |
| 599 | ))} |
| 600 | </ul> |
| 601 | </Notice> |
| 602 | </div> |
| 603 | )} |
| 604 | {guard.empty && ( |
| 605 | <div className="mb-4"> |
| 606 | <Notice tone="warn"> |
| 607 | Every dataset answered with no rows{guard.last_run ? ` in the run for ${guard.last_run}` : ""}, the month so far included: likely the wrong |
| 608 | account or a token that cannot see its analytics. The watcher sees nothing. |
| 609 | </Notice> |
| 610 | </div> |
| 611 | )} |
| 612 | {failed?.section === "platform" && ( |
| 613 | <div className="mb-4"> |
| 614 | <Notice tone="error">{failed.error}</Notice> |
| 615 | </div> |
| 616 | )} |
| 617 | <div className="grid gap-3 lg:grid-cols-2"> |
| 618 | {PAUSE_LEVELS.map(({ level, title, stops }) => { |
| 619 | const state = guard.levels.find((l) => l.level === level); |
| 620 | const paused = state?.paused ?? false; |
| 621 | const error = failed?.section === `pause-${level}` ? failed.error : null; |
| 622 | return ( |
| 623 | <div key={level} className={`rounded-lg border p-4 ${paused ? "border-danger/50 bg-danger/8" : "border-line"}`}> |
| 624 | <div className="flex items-center justify-between gap-2"> |
| 625 | <span className="font-medium">{title}</span> |
| 626 | {paused ? <Badge tone="danger">Paused</Badge> : <Badge tone="mint">Running</Badge>} |
| 627 | </div> |
| 628 | <p className="mt-1 text-xs text-muted">{stops}</p> |
| 629 | {state?.set_at && ( |
| 630 | <p className="mt-2 text-xs text-faint"> |
| 631 | {paused ? "Paused" : "Resumed"} <When at={state.set_at} time /> by {state.auto ? "the usage watcher" : state.set_by} |
| 632 | {state.note ? `: “${state.note}”` : ""} |
| 633 | </p> |
| 634 | )} |
| 635 | <form method="post" action="#platform" className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-end"> |
| 636 | <input type="hidden" name="intent" value={paused ? "resume" : "pause"} /> |
| 637 | <input type="hidden" name="level" value={level} /> |
| 638 | <Field label={paused ? "Why resume it" : "Why pause it"} hint="Recorded in the audit log."> |
| 639 | <Input |
| 640 | name="note" |
| 641 | required |
| 642 | minLength={5} |
| 643 | maxLength={500} |
| 644 | placeholder={paused ? "e.g. Fixed the loop in search" : "e.g. KV lists runaway"} |
| 645 | /> |
| 646 | </Field> |
| 647 | <Button type="submit" variant={paused ? "primary" : "danger"}> |
| 648 | {paused ? "Resume" : "Pause"} |
| 649 | </Button> |
| 650 | </form> |
| 651 | {error && ( |
| 652 | <div className="mt-2"> |
| 653 | <Notice tone="error">{error}</Notice> |
| 654 | </div> |
| 655 | )} |
| 656 | </div> |
| 657 | ); |
| 658 | })} |
| 659 | </div> |
| 660 | <p className="mt-3 text-xs text-muted"> |
| 661 | A severe breach may pause: {guard.auto_pause.length ? guard.auto_pause.join(", ") : "nothing (AUTO_PAUSE is empty)"}. |
| 662 | </p> |
| 663 | |
| 664 | <h3 className="mt-6 text-sm font-medium">Breaches in the last 24 hours</h3> |
| 665 | {guard.breaches.length === 0 ? ( |
| 666 | <p className="mt-2 text-sm text-muted">None.</p> |
| 667 | ) : ( |
| 668 | <ul className="mt-2 space-y-2 text-sm"> |
| 669 | {guard.breaches.map((b) => ( |
| 670 | <li key={b.id} className="rounded-md border border-line px-3 py-2"> |
| 671 | <div className="flex flex-wrap items-center gap-2"> |
| 672 | <Badge tone={b.severe ? "danger" : "warn"}>{b.severe ? "Severe" : b.rule === "spike" ? "Spike" : "Over threshold"}</Badge> |
| 673 | <span className="text-xs text-faint"> |
| 674 | <When at={b.opened_at} time /> |
| 675 | {b.emailed_at ? ", emailed" : ""} |
| 676 | </span> |
| 677 | </div> |
| 678 | <p className="mt-1 text-fg-soft">{b.detail}</p> |
| 679 | </li> |
| 680 | ))} |
| 681 | </ul> |
| 682 | )} |
| 683 | |
| 684 | <UsageTable title={guard.hour ? `The hour from ${guard.hour}` : "The last hour"} metrics={guard.last_hour} hourly /> |
| 685 | <UsageTable title={`${guard.month}, so far`} metrics={guard.month_to_date} hourly={false} /> |
| 686 | <p className="mt-3 text-xs text-muted"> |
| 687 | Ids are Cloudflare's. <code>node scripts/ops/platform-usage.mjs</code> names them and shows the last 24 hours per script, queue, database and |
| 688 | namespace. |
| 689 | </p> |
| 690 | </> |
| 691 | )} |
| 692 | </Section> |
| 693 | ); |
| 694 | } |
| 695 | |
| 696 | function UsageTable({ title, metrics, hourly }: { title: string; metrics: PlatformMetric[]; hourly: boolean }) { |
| 697 | return ( |
| 698 | <> |
| 699 | <h3 className="mt-6 text-sm font-medium">{title}</h3> |
| 700 | {metrics.length === 0 ? ( |
| 701 | <p className="mt-2 text-sm text-muted">Nothing read yet.</p> |
| 702 | ) : ( |
| 703 | <div className="-mx-4 mt-2 overflow-x-auto sm:-mx-5"> |
| 704 | <table className="w-full min-w-xl text-sm"> |
| 705 | <thead> |
| 706 | <tr className="border-b border-line text-left text-xs text-muted"> |
| 707 | <th className="px-4 py-2 font-medium sm:px-5">Metric</th> |
| 708 | <th className="px-4 py-2 text-right font-medium">Count</th> |
| 709 | {hourly && <th className="px-4 py-2 text-right font-medium">Of threshold</th>} |
| 710 | <th className="px-4 py-2 font-medium sm:pr-5">Most from</th> |
| 711 | </tr> |
| 712 | </thead> |
| 713 | <tbody> |
| 714 | {metrics.map((m) => { |
| 715 | const share = thresholdShare(m.value, m.threshold); |
| 716 | return ( |
| 717 | <tr key={m.metric} className="border-b border-line last:border-0"> |
| 718 | <td className="px-4 py-2.5 sm:px-5">{m.title}</td> |
| 719 | <td className="tabular px-4 py-2.5 text-right">{count(m.value)}</td> |
| 720 | {hourly && ( |
| 721 | <td className={`tabular px-4 py-2.5 text-right ${share != null && share > 100 ? "text-danger" : "text-muted"}`}> |
| 722 | {share == null ? "—" : `${share}% of ${count(m.threshold)}`} |
| 723 | </td> |
| 724 | )} |
| 725 | <td className="max-w-[16rem] truncate px-4 py-2.5 text-xs text-faint sm:pr-5"> |
| 726 | {m.top_name ? `${m.top_name} (${count(m.top_value ?? 0)})` : "—"} |
| 727 | </td> |
| 728 | </tr> |
| 729 | ); |
| 730 | })} |
| 731 | </tbody> |
| 732 | </table> |
| 733 | </div> |
| 734 | )} |
| 735 | </> |
| 736 | ); |
| 737 | } |