Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

152 linesCodeBlameRaw
1import { Suspense } from "react";
2import { Await, data } from "react-router";
3
4import { PRODUCTS, type UsageReport } from "@g1t/contracts";
5
6import type { Route } from "./+types/usage";
7import { Breakdown, IncludedAndCredit, UsageChart, UsageFilterBar, type UsageFilters, UsageSkeleton } from "../../components/usage";
8import { columns, defaultGrain, type Grain, type GroupBy, resolveRange } from "../../lib/usage";
9import { page } from "../../lib/meta";
10import { billing } from "../../lib/services.server";
11import { getViewer, managesBilling, roleIn } from "../../lib/session.server";
12import { Unavailable } from "../../components/boundary";
13
14/** Where the usage API is documented. */
15export const USAGE_API = "https://docs.g1t.sh/reference/api/#usage";
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Usage · ${params.owner} · g1t` });
19}
20
21/** The page's filters from its address: a filtered page is a link. */
22export function readFilters(url: URL, fixedProject?: string): UsageFilters {
23 const q = url.searchParams;
24 const { period, from, until } = resolveRange(q.get("period"), { from: q.get("from"), until: q.get("until") });
25 const known = new Set<string>(PRODUCTS.map((p) => p.key));
26 const grain = q.get("grain");
27 const group = q.get("group");
28 return {
29 period,
30 from,
31 until,
32 products: q.getAll("product").filter((p) => known.has(p)),
33 projects: fixedProject
34 ? [fixedProject]
35 : q
36 .getAll("project")
37 .filter((p) => /^[a-z0-9._-]+\/[a-z0-9._-]+$/i.test(p))
38 .map((p) => p.toLowerCase()),
39 group: group === "project" && !fixedProject ? "project" : group === "day" ? "day" : "product",
40 grain: grain === "day" || grain === "week" || grain === "month" ? grain : defaultGrain(from, until),
41 cumulative: q.get("cumulative") === "1",
42 };
43}
44
45/** The address with one filter changed. */
46export function hrefWith(url: string, change: Record<string, string | null>): string {
47 const next = new URL(url);
48 for (const [key, value] of Object.entries(change)) {
49 if (value == null) next.searchParams.delete(key);
50 else next.searchParams.set(key, value);
51 }
52 return `${next.pathname}?${next.searchParams.toString()}`;
53}
54
55export async function loader({ params, context, request }: Route.LoaderArgs) {
56 const viewer = getViewer(context);
57 const role = roleIn(viewer, params.owner);
58 if (!role) throw data(null, { status: 404 });
59 const slug = params.owner.toLowerCase();
60 const url = new URL(request.url);
61 const filters = readFilters(url);
62 // Streamed: the page's frame and filters come at once, each region's
63 // skeleton holds its place, and the report fills them in.
64 const report = billing
65 .usageReport(slug, viewer, { from: filters.from, until: filters.until, products: filters.products, projects: filters.projects })
66 .then((result) => (result.ok ? { report: result.value, error: null } : { report: null, error: result.error.message }));
67 return { slug, owner: managesBilling(getViewer(context), slug), filters, url: url.toString(), report };
68}
69
70export function UsageView({
71 slug,
72 owner,
73 filters,
74 url,
75 report,
76 error,
77 fixedProject,
78}: {
79 slug: string;
80 owner: boolean;
81 filters: UsageFilters;
82 url: string;
83 report: UsageReport | null;
84 error: string | null;
85 fixedProject?: string;
86}) {
87 const bar = (
88 <UsageFilterBar
89 filters={filters}
90 products={PRODUCTS}
91 projects={report?.projects ?? filters.projects}
92 report={report}
93 apiHref={USAGE_API}
94 fixedProject={fixedProject}
95 />
96 );
97 if (!report) {
98 return (
99 <div className="space-y-6">
100 {bar}
101 <p className="rounded-lg border border-danger/40 bg-danger/5 px-4 py-2.5 text-sm">{error ?? "Usage could not be read. Try again in a minute."}</p>
102 </div>
103 );
104 }
105 const cols = columns(report.days, report.from, report.until, filters.grain as Grain, filters.cumulative);
106 return (
107 <div className="space-y-6">
108 {bar}
109 {!fixedProject && <IncludedAndCredit report={report} billingHref={`/${slug}/-/billing`} owner={owner} />}
110 <UsageChart
111 columns={cols}
112 products={report.products}
113 grain={filters.grain}
114 cumulative={filters.cumulative}
115 grainHref={(grain) => hrefWith(url, { grain })}
116 cumulativeHref={hrefWith(url, { cumulative: filters.cumulative ? null : "1" })}
117 />
118 <Breakdown report={report} group={filters.group as GroupBy} columns={cols} projectHref={fixedProject ? undefined : (project) => `/${project}/usage`} />
119 <p className="text-xs text-faint">
120 Every amount is usage at price: what was charged, plus what included usage, credit or a discount paid for it, to the cent; under a cent reads &lt;$0.01.
121 Storage, git operations, scans and search are metered through the month and counted in it as they go; they are charged when it closes, on the same terms
122 as the rest.
123 </p>
124 </div>
125 );
126}
127
128export default function UsagePage({ loaderData }: Route.ComponentProps) {
129 const { slug, owner, filters, url, report } = loaderData;
130 return (
131 <Suspense
132 fallback={
133 <UsageSkeleton>
134 <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} />
135 </UsageSkeleton>
136 }
137 >
138 {/* When billing does not answer, the filters stay and the report says so in its place. */}
139 <Await
140 resolve={report}
141 errorElement={
142 <div className="space-y-6">
143 <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} />
144 <Unavailable what="Usage" />
145 </div>
146 }
147 >
148 {(loaded) => <UsageView slug={slug} owner={owner} filters={filters} url={url} report={loaded.report} error={loaded.error} />}
149 </Await>
150 </Suspense>
151 );
152}