g1t/apps/sudo/app/components/costs-header.tsx

141 lines5,560 bytesCodeBlame
1/**
2 * What both costs pages open with: the title, the run button, the two
3 * tabs, the range, and what needs attention.
4 */
5import { Link } from "react-router";
6
7import type { CostsReport } from "@g1t/contracts";
8
9import { Button, Notice, PageHeader } from "~/components/ui";
10
11export const RANGES = [7, 30, 90];
12
13export 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). */
20export 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
29const TABS = [
30 { page: "costs", title: "Costs & margin" },
31 { page: "bill", title: "Bill & pricing" },
32] as const;
33
34export 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 &amp; 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 &amp; 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 */
127function 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}