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.

292 linesCodeBlameRaw
1import { useRef } from "react";
2import { Form, Link, data, useNavigation } from "react-router";
3import type { DeliveryMetrics } from "@g1t/contracts";
4import type { Route } from "./+types/insights";
5import { ButtonLink } from "../../components/ui";
6import { Alert } from "../../components/ui/alert";
7import { Card } from "../../components/ui/card";
8import { Hint } from "../../components/ui/hint";
9import { SelectField } from "../../components/ui/select";
10import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table";
11import { page } from "../../lib/meta";
12import { money } from "../../lib/money";
13import { billing, work } from "../../lib/services.server";
14import { getViewer, roleIn } from "../../lib/session.server";
15import { workspaceProjects } from "../../lib/workspace-projects.server";
16import { resolveRange } from "../../lib/usage";
17import { deliveryDays, deliveryTotals, duration, projectCosts, usageKey } from "../../lib/workspace-insights";
18
19export function meta({ params, ...args }: Route.MetaArgs) {
20 return page(args, { title: `Insights · ${params.owner} · g1t` });
21}
22
23const PERIODS = [
24 { value: "7d", label: "Last 7 days" },
25 { value: "30d", label: "Last 30 days" },
26 { value: "90d", label: "Last 90 days" },
27];
28
29export async function loader({ params, context, request }: Route.LoaderArgs) {
30 const viewer = getViewer(context);
31 const slug = params.owner.toLowerCase();
32 if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
33 const query = new URL(request.url).searchParams;
34 const period = PERIODS.some((p) => p.value === query.get("period")) ? query.get("period")! : "30d";
35 const { from, until } = resolveRange(period, {});
36 const listed = await workspaceProjects(slug, viewer);
37 if (!listed.ok) throw data("Projects could not be loaded. Please try again.", { status: 503 });
38 const selected = query.get("project") ?? "";
39 if (selected && !listed.value.some((p) => p.slug === selected)) throw data(null, { status: 404 });
40 // Billing counts usage by repository (`owner/name`), as Usage filters it.
41 const projects = listed.value
42 .filter((p) => !selected || p.slug === selected)
43 .map((p) => ({ slug: p.slug, name: p.name, repoId: p.source.repoId, usage: usageKey(p.source.repo) }));
44 const ids = [...new Set(projects.map((p) => p.repoId))];
45 const usageTask = billing.usageReport(slug, viewer, { from, until, projects: selected ? projects.map((p) => p.usage) : [] }).catch(() => null);
46 // Bounded batches cover every project, without one RPC per repository.
47 const batches = await Promise.allSettled(
48 Array.from({ length: Math.ceil(ids.length / 100) }, (_, i) => work.deliveryInsights(ids.slice(i * 100, i * 100 + 100), viewer, from, until)),
49 );
50 const metrics: DeliveryMetrics[] = [];
51 const days: { day: string; merged: number }[] = [];
52 let checksUnavailable = false;
53 for (const batch of batches) {
54 if (batch.status !== "fulfilled") continue;
55 metrics.push(...batch.value.projects);
56 days.push(...batch.value.days);
57 checksUnavailable ||= batch.value.checksUnavailable;
58 }
59 const usage = await usageTask;
60 return {
61 slug,
62 period,
63 from,
64 until,
65 selected,
66 choices: listed.value.map((p) => ({ value: p.slug, label: p.name })),
67 projects,
68 metrics,
69 days: deliveryDays(from, until, days),
70 deliveryError: batches.some((b) => b.status === "rejected"),
71 checksUnavailable,
72 report: usage?.ok ? usage.value : null,
73 };
74}
75
76/** Money, or a dash when there is no sum to show. */
77const cost = (micros: number | null) => (micros == null ? "—" : money(micros));
78
79function Stat({ label, value }: { label: string; value: string | number }) {
80 return (
81 <Card className="p-4">
82 <p className="text-xs text-muted">{label}</p>
83 <p className="mt-2 text-2xl font-semibold tabular-nums">{value}</p>
84 </Card>
85 );
86}
87
88export default function Insights({ loaderData: d }: Route.ComponentProps) {
89 const form = useRef<HTMLFormElement>(null);
90 const submit = () => form.current?.requestSubmit();
91 const pending = useNavigation().state !== "idle";
92 const totals = deliveryTotals(d.metrics);
93 const costs = projectCosts(d.report);
94 const byId = new Map(d.metrics.map((m) => [m.repoId, m]));
95 // A project whose repository the viewer cannot read is left out of the
96 // totals (its row shows dashes); only a batch that failed hides them.
97 const delivery = !d.deliveryError;
98 const checks = delivery && !d.checksUnavailable;
99 const price = d.report?.totals.priceMicros ?? null;
100 const peak = Math.max(1, ...d.days.map((day) => day.merged));
101 const selectedUsage = d.projects.find((p) => p.slug === d.selected)?.usage;
102 const usageHref = `/${d.slug}/-/usage?period=${d.period}${selectedUsage ? `&project=${encodeURIComponent(selectedUsage)}` : ""}`;
103 return (
104 <div className="space-y-6" aria-busy={pending}>
105 <Form ref={form} method="get" preventScrollReset className="flex flex-wrap items-center gap-2">
106 <SelectField
107 key={`period-${d.period}`}
108 name="period"
109 defaultValue={d.period}
110 afterChange={submit}
111 size="sm"
112 className="w-auto"
113 aria-label="Period"
114 options={PERIODS}
115 />
116 <SelectField
117 key={`project-${d.selected}`}
118 name="project"
119 defaultValue={d.selected}
120 afterChange={submit}
121 size="sm"
122 className="w-auto max-w-64"
123 aria-label="Project"
124 options={[{ value: "", label: "All projects" }, ...d.choices]}
125 />
126 <ButtonLink to={usageHref} variant="link" size="inline" className="ml-auto text-sm">
127 Explore costs →
128 </ButtonLink>
129 </Form>
130 <p className="text-xs text-muted">
131 {d.from} – {d.until}, inclusive, UTC.
132 </p>
133 {!delivery && (
134 <Alert tone="warn" role="status">
135 Delivery could not be read for some projects, so its numbers are hidden. Reload the page to try again.
136 </Alert>
137 )}
138 {delivery && d.checksUnavailable && (
139 <Alert tone="warn" role="status">
140 g1t Actions could not be read, so the checks waiting now are not shown. Reload the page to try again.
141 </Alert>
142 )}
143 {!d.report && (
144 <Alert tone="warn" role="status">
145 Costs could not be read right now. Reload the page to try again.
146 </Alert>
147 )}
148 {!d.projects.length ? (
149 <Card className="p-8 text-center">
150 <h2 className="font-medium">No projects yet</h2>
151 <p className="mt-2 text-sm text-muted">Delivery and cost insights will appear as your workspace starts working.</p>
152 <ButtonLink to={`/new?workspace=${d.slug}`} variant="link" size="inline" className="mt-4">
153 Create a project
154 </ButtonLink>
155 </Card>
156 ) : (
157 <>
158 <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
159 <Stat label="Merged changes" value={delivery ? totals.merged : "—"} />
160 <Stat label="Average lead time" value={duration(delivery && totals.merged ? totals.leadSeconds / totals.merged : null)} />
161 <Stat label="Usage at price" value={cost(price)} />
162 <Stat label="Cost per merged change" value={cost(delivery && totals.merged && price != null ? price / totals.merged : null)} />
163 </div>
164 {delivery && (
165 <section>
166 <h2 className="mb-3 font-medium">Merged changes by day</h2>
167 <Card className="p-4">
168 <div
169 className="flex h-36 items-end gap-0.5"
170 role="img"
171 aria-label={`${totals.merged} changes merged from ${d.from} through ${d.until}. Daily values are in the table below.`}
172 >
173 {d.days.map((day) => (
174 <Hint key={day.day} label={`${day.day}: ${day.merged} merged`}>
175 <div className="flex h-full min-w-0 flex-1 items-end">
176 <div className="w-full rounded-t bg-accent" style={{ height: `${day.merged ? Math.max(2, (day.merged / peak) * 100) : 0}%` }} />
177 </div>
178 </Hint>
179 ))}
180 </div>
181 <div className="mt-2 flex justify-between text-xs text-muted">
182 <span>{d.from}</span>
183 <span>{d.until}</span>
184 </div>
185 {!totals.merged && <p className="mt-3 text-sm text-muted">No changes merged in this period.</p>}
186 <details className="mt-3 text-xs text-muted">
187 <summary className="cursor-pointer">View daily values</summary>
188 <Table className="mt-2 text-xs">
189 <TableHeader>
190 <TableRow>
191 <TableHead scope="col">UTC day</TableHead>
192 <TableHead scope="col">Merged</TableHead>
193 </TableRow>
194 </TableHeader>
195 <TableBody>
196 {d.days.map((day) => (
197 <TableRow key={day.day}>
198 <TableHead scope="row" className="font-normal">
199 {day.day}
200 </TableHead>
201 <TableCell className="tabular-nums">{day.merged}</TableCell>
202 </TableRow>
203 ))}
204 </TableBody>
205 </Table>
206 </details>
207 </Card>
208 </section>
209 )}
210 <section>
211 <h2 className="mb-3 font-medium">Waiting now</h2>
212 <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
213 <Stat label="Awaiting a first review" value={delivery ? totals.unreviewed : "—"} />
214 <Stat label="In the merge queue" value={delivery ? totals.queued : "—"} />
215 <Stat label="Checks running" value={checks ? totals.checking : "—"} />
216 <Stat label="Checks failed" value={checks ? totals.failed : "—"} />
217 </div>
218 </section>
219 <section>
220 <h2 className="mb-3 font-medium">By project</h2>
221 <Card className="overflow-hidden">
222 <Table>
223 <TableCaption className="sr-only">
224 Delivery and costs for {d.from} through {d.until}
225 </TableCaption>
226 <TableHeader>
227 <TableRow>
228 {["Project", "Opened", "Merged", "Open now", "Avg. lead time", "Avg. first review", "Cost", "Cost / merge"].map((h) => (
229 <TableHead key={h} scope="col">
230 {h}
231 </TableHead>
232 ))}
233 </TableRow>
234 </TableHeader>
235 <TableBody>
236 {d.projects.map((p) => {
237 const m = delivery ? byId.get(p.repoId) : undefined;
238 const spent = d.report ? (costs.get(p.usage) ?? 0) : null;
239 const cells = [
240 m?.opened ?? "—",
241 m?.merged ?? "—",
242 m?.active ?? "—",
243 duration(m?.merged ? m.leadSeconds / m.merged : null),
244 duration(m?.reviewed ? m.reviewSeconds / m.reviewed : null),
245 cost(spent),
246 cost(m?.merged && spent != null ? spent / m.merged : null),
247 ];
248 return (
249 <TableRow key={p.slug}>
250 <TableHead scope="row" className="text-fg">
251 <Link className="hover:text-accent" to={`/${d.slug}/${p.slug}/pulls`}>
252 {p.name}
253 </Link>
254 </TableHead>
255 {cells.map((v, i) => (
256 <TableCell key={i} className="whitespace-nowrap tabular-nums">
257 {v}
258 </TableCell>
259 ))}
260 </TableRow>
261 );
262 })}
263 </TableBody>
264 </Table>
265 </Card>
266 </section>
267 {d.report && (
268 <section>
269 <h2 className="mb-3 font-medium">Cost by product</h2>
270 <Card className="divide-y divide-line">
271 {d.report.products.map((p) => (
272 <div key={p.key} className="flex justify-between px-4 py-3 text-sm">
273 <span>{p.label}</span>
274 <span className="tabular-nums">{money(p.micros)}</span>
275 </div>
276 ))}
277 {!d.report.products.length && <p className="p-4 text-sm text-muted">No usage in this period.</p>}
278 </Card>
279 </section>
280 )}
281 <p className="text-xs leading-relaxed text-muted">
282 Lead time runs from a pull request’s opening to its merge, for those merged in this period. First review runs from opening to the first approval or
283 request for changes by a person other than its owner (not g1t or a workspace agent), for first reviews given in this period. Waiting counts are as
284 of now and may overlap. Checks count each open pull request once, by its latest commit’s statuses, check runs and g1t Actions jobs: failed if any
285 failed, else running if any is still going. Costs are usage at price, before what the plan, credit or discounts paid; cost per merged change divides
286 the period’s usage by its merges, not what each change cost. A dash means no sample, or no data to show.
287 </p>
288 </>
289 )}
290 </div>
291 );
292}