| 1 | import { useRef } from "react"; |
| 2 | import { Form, Link, data, useNavigation } from "react-router"; |
| 3 | import type { DeliveryMetrics } from "@g1t/contracts"; |
| 4 | import type { Route } from "./+types/insights"; |
| 5 | import { ButtonLink } from "../../components/ui"; |
| 6 | import { Alert } from "../../components/ui/alert"; |
| 7 | import { Card } from "../../components/ui/card"; |
| 8 | import { Hint } from "../../components/ui/hint"; |
| 9 | import { SelectField } from "../../components/ui/select"; |
| 10 | import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "../../components/ui/table"; |
| 11 | import { page } from "../../lib/meta"; |
| 12 | import { money } from "../../lib/money"; |
| 13 | import { billing, work } from "../../lib/services.server"; |
| 14 | import { getViewer, roleIn } from "../../lib/session.server"; |
| 15 | import { workspaceProjects } from "../../lib/workspace-projects.server"; |
| 16 | import { resolveRange } from "../../lib/usage"; |
| 17 | import { deliveryDays, deliveryTotals, duration, projectCosts, usageKey } from "../../lib/workspace-insights"; |
| 18 | |
| 19 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 20 | return page(args, { title: `Insights · ${params.owner} · g1t` }); |
| 21 | } |
| 22 | |
| 23 | const PERIODS = [ |
| 24 | { value: "7d", label: "Last 7 days" }, |
| 25 | { value: "30d", label: "Last 30 days" }, |
| 26 | { value: "90d", label: "Last 90 days" }, |
| 27 | ]; |
| 28 | |
| 29 | export 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. */ |
| 79 | const cost = (micros: number | null) => (micros == null ? "—" : money(micros)); |
| 80 | |
| 81 | function 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 | |
| 90 | export 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 | } |