g1t/apps/sudo/app/components/costs-header.tsx
| 1 | /** |
| 2 | * What both costs pages open with: the title, the run button, the two |
| 3 | * tabs, the range, and what needs attention. |
| 4 | */ |
| 5 | import { Link } from "react-router"; |
| 6 | |
| 7 | import type { CostsReport } from "@g1t/contracts"; |
| 8 | |
| 9 | import { Button, Notice, PageHeader } from "~/components/ui"; |
| 10 | |
| 11 | export const RANGES = [7, 30, 90]; |
| 12 | |
| 13 | export function chip(active: boolean) { |
| 14 | return `inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs whitespace-nowrap transition-colors ${ |
| 15 | active ? "border-merged/50 bg-merged/10 text-merged" : "border-line text-muted hover:border-line-strong hover:text-fg" |
| 16 | }`; |
| 17 | } |
| 18 | |
| 19 | /** A costs page's address with its range (and, on Costs & margin, its product). */ |
| 20 | export function costsHref(page: "costs" | "bill", range: number, bucket: string | null = null) { |
| 21 | const params = new URLSearchParams(); |
| 22 | if (range !== 30) params.set("days", String(range)); |
| 23 | if (bucket && page === "costs") params.set("product", bucket); |
| 24 | const query = params.toString(); |
| 25 | const path = page === "costs" ? "/costs" : "/costs/bill"; |
| 26 | return query ? `${path}?${query}` : path; |
| 27 | } |
| 28 | |
| 29 | const TABS = [ |
| 30 | { page: "costs", title: "Costs & margin" }, |
| 31 | { page: "bill", title: "Bill & pricing" }, |
| 32 | ] as const; |
| 33 | |
| 34 | export function CostsHeader({ |
| 35 | page, |
| 36 | range, |
| 37 | report, |
| 38 | done, |
| 39 | runError, |
| 40 | description, |
| 41 | }: { |
| 42 | page: "costs" | "bill"; |
| 43 | range: number; |
| 44 | report: CostsReport | null; |
| 45 | done: string | null; |
| 46 | runError: string | null; |
| 47 | description: string; |
| 48 | }) { |
| 49 | // Margin under the floor at the top of Costs & margin; drift and leaks |
| 50 | // at the top of Bill & pricing; workspaces on Reach out. |
| 51 | const alerts = report?.alerts ?? []; |
| 52 | const here = alerts.filter((a) => (page === "costs" ? a.kind === "overall" || a.kind === "margin" : a.kind === "drift" || a.kind === "leak")); |
| 53 | const drift = alerts.filter((a) => a.kind === "drift" || a.kind === "leak").length; |
| 54 | const workspaces = alerts.filter((a) => a.kind === "workspace").length; |
| 55 | const margin = alerts.filter((a) => a.kind === "overall" || a.kind === "margin").length; |
| 56 | return ( |
| 57 | <> |
| 58 | <PageHeader title={TABS.find((t) => t.page === page)!.title} description={description} actions={<RunButton />} /> |
| 59 | <nav aria-label="Costs" className="mt-5 flex flex-wrap gap-x-5 gap-y-2 border-b border-line"> |
| 60 | {TABS.map((tab) => ( |
| 61 | <Link |
| 62 | key={tab.page} |
| 63 | to={costsHref(tab.page, range)} |
| 64 | aria-current={tab.page === page ? "page" : undefined} |
| 65 | className={`-mb-px border-b-2 pb-2 text-sm ${tab.page === page ? "border-merged text-fg" : "border-transparent text-muted hover:text-fg"}`} |
| 66 | > |
| 67 | {tab.title} |
| 68 | </Link> |
| 69 | ))} |
| 70 | </nav> |
| 71 | <nav aria-label="Range" className="mt-4 flex flex-wrap gap-2"> |
| 72 | {RANGES.map((days) => ( |
| 73 | <Link key={days} to={costsHref(page, days)} aria-current={days === range ? "page" : undefined} className={chip(days === range)}> |
| 74 | Last {days} days |
| 75 | </Link> |
| 76 | ))} |
| 77 | </nav> |
| 78 | <div className="mt-5 space-y-3 empty:hidden"> |
| 79 | {done && <Notice tone="ok">{done}</Notice>} |
| 80 | {runError && <Notice tone="error">{runError}</Notice>} |
| 81 | {report && !report.configured && ( |
| 82 | <Notice tone="warn"> |
| 83 | Billing cannot read Cloudflare's bill: set the <code className="font-mono">CLOUDFLARE_BILLING_TOKEN</code> secret on g1t-billing (Account: |
| 84 | Billing Read and Account Analytics Read). Until then the figures are g1t's own, and Cloudflare's cost shows as nothing. |
| 85 | </Notice> |
| 86 | )} |
| 87 | {here.map((alert) => ( |
| 88 | <Notice key={alert.id} tone={alert.kind === "drift" ? "warn" : "error"}> |
| 89 | {alert.detail} <span className="text-faint">Since {alert.since}.</span> |
| 90 | </Notice> |
| 91 | ))} |
| 92 | {page === "costs" && (drift > 0 || workspaces > 0) && ( |
| 93 | <p className="text-xs text-muted"> |
| 94 | Also open:{" "} |
| 95 | {drift > 0 && ( |
| 96 | <Link to={costsHref("bill", range) + "#drift"} className="underline underline-offset-2"> |
| 97 | {drift} on Bill & pricing |
| 98 | </Link> |
| 99 | )} |
| 100 | {drift > 0 && workspaces > 0 && ", "} |
| 101 | {workspaces > 0 && ( |
| 102 | <Link to="/reach-out?kind=cost_over_revenue" className="underline underline-offset-2"> |
| 103 | {workspaces} workspace{workspaces === 1 ? "" : "s"} on Reach out |
| 104 | </Link> |
| 105 | )} |
| 106 | . |
| 107 | </p> |
| 108 | )} |
| 109 | {page === "bill" && margin > 0 && ( |
| 110 | <p className="text-xs text-muted"> |
| 111 | Also open:{" "} |
| 112 | <Link to={costsHref("costs", range)} className="underline underline-offset-2"> |
| 113 | margin under the floor on Costs & margin |
| 114 | </Link> |
| 115 | . |
| 116 | </p> |
| 117 | )} |
| 118 | </div> |
| 119 | </> |
| 120 | ); |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * The button that runs the nightly analysis now. sudo runs no JavaScript, |
| 125 | * so app.css's run-button rules say it is running while the page waits. |
| 126 | */ |
| 127 | function RunButton() { |
| 128 | return ( |
| 129 | <form method="post" className="flex items-center gap-3"> |
| 130 | <input type="hidden" name="intent" value="run" /> |
| 131 | <Button variant="quiet" type="submit" className="run-button"> |
| 132 | <span className="run-idle">Run the analysis now</span> |
| 133 | <span className="run-busy"> |
| 134 | <span className="run-spinner" aria-hidden="true" /> |
| 135 | Running the analysis… |
| 136 | </span> |
| 137 | </Button> |
| 138 | <span className="run-note text-xs text-muted">Reading the bill and reconciling 31 days; about a minute.</span> |
| 139 | </form> |
| 140 | ); |
| 141 | } |