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.

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