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.

147 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 ? [fixedProject] : q.getAll("project").filter((p) => /^[a-z0-9._-]+\/[a-z0-9._-]+$/i.test(p)).map((p) => p.toLowerCase()),
34 group: group === "project" && !fixedProject ? "project" : group === "day" ? "day" : "product",
35 grain: grain === "day" || grain === "week" || grain === "month" ? grain : defaultGrain(from, until),
36 cumulative: q.get("cumulative") === "1",
37 };
38}
39
40/** The address with one filter changed. */
41export function hrefWith(url: string, change: Record<string, string | null>): string {
42 const next = new URL(url);
43 for (const [key, value] of Object.entries(change)) {
44 if (value == null) next.searchParams.delete(key);
45 else next.searchParams.set(key, value);
46 }
47 return `${next.pathname}?${next.searchParams.toString()}`;
48}
49
50export async function loader({ params, context, request }: Route.LoaderArgs) {
51 const viewer = getViewer(context);
52 const role = roleIn(viewer, params.owner);
53 if (!role) throw data(null, { status: 404 });
54 const slug = params.owner.toLowerCase();
55 const url = new URL(request.url);
56 const filters = readFilters(url);
57 // Streamed: the page's frame and filters come at once, each region's
58 // skeleton holds its place, and the report fills them in.
59 const report = billing
60 .usageReport(slug, viewer, { from: filters.from, until: filters.until, products: filters.products, projects: filters.projects })
61 .then((result) => (result.ok ? { report: result.value, error: null } : { report: null, error: result.error.message }));
62 return { slug, owner: managesBilling(getViewer(context), slug), filters, url: url.toString(), report };
63}
64
65export function UsageView({
66 slug,
67 owner,
68 filters,
69 url,
70 report,
71 error,
72 fixedProject,
73}: {
74 slug: string;
75 owner: boolean;
76 filters: UsageFilters;
77 url: string;
78 report: UsageReport | null;
79 error: string | null;
80 fixedProject?: string;
81}) {
82 const bar = (
83 <UsageFilterBar
84 filters={filters}
85 products={PRODUCTS}
86 projects={report?.projects ?? filters.projects}
87 report={report}
88 apiHref={USAGE_API}
89 fixedProject={fixedProject}
90 />
91 );
92 if (!report) {
93 return (
94 <div className="space-y-6">
95 {bar}
96 <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>
97 </div>
98 );
99 }
100 const cols = columns(report.days, report.from, report.until, filters.grain as Grain, filters.cumulative);
101 return (
102 <div className="space-y-6">
103 {bar}
104 {!fixedProject && <IncludedAndCredit report={report} billingHref={`/${slug}/-/billing`} owner={owner} />}
105 <UsageChart
106 columns={cols}
107 products={report.products}
108 grain={filters.grain}
109 cumulative={filters.cumulative}
110 grainHref={(grain) => hrefWith(url, { grain })}
111 cumulativeHref={hrefWith(url, { cumulative: filters.cumulative ? null : "1" })}
112 />
113 <Breakdown report={report} group={filters.group as GroupBy} columns={cols} projectHref={fixedProject ? undefined : (project) => `/${project}/usage`} />
114 <p className="text-xs text-faint">
115 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
116 cent reads &lt;$0.01. Storage, git operations, scans and search are metered through the month and counted in it as they go; they
117 are charged when it closes, on the same terms as the rest.
118 </p>
119 </div>
120 );
121}
122
123export default function UsagePage({ loaderData }: Route.ComponentProps) {
124 const { slug, owner, filters, url, report } = loaderData;
125 return (
126 <Suspense
127 fallback={
128 <UsageSkeleton>
129 <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} />
130 </UsageSkeleton>
131 }
132 >
133 {/* When billing does not answer, the filters stay and the report says so in its place. */}
134 <Await
135 resolve={report}
136 errorElement={
137 <div className="space-y-6">
138 <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} />
139 <Unavailable what="Usage" />
140 </div>
141 }
142 >
143 {(loaded) => <UsageView slug={slug} owner={owner} filters={filters} url={url} report={loaded.report} error={loaded.error} />}
144 </Await>
145 </Suspense>
146 );
147}