g1t/apps/web/app/routes/workspace/overview.tsx

398 lines17,355 bytesCodeBlame
1import { ArrowRight, ArrowUpRight, Box, CircleDot, GitBranch, GitPullRequest, KeyRound, Lock, Plus, Rocket } from "lucide-react";
2import { Link } from "react-router";
3
4import type { PackageSummary, Project, ProjectDeploys, User } from "@g1t/contracts";
5
6import type { Route } from "./+types/overview";
7import { host, StatusDot } from "../../components/deploy";
8import { Avatar, ButtonLink, CopyLine, Pill, TimeAgo } from "../../components/ui";
9import { UsageCard } from "../../components/usage-card";
10import { PullIcon } from "../../components/work-icons";
11import { planStatus, type UsageGlance, usageGlance } from "../../lib/billing";
12import { openedBy } from "../../lib/opened-by";
13import { libraryPackages, packageLine, packagePath } from "../../lib/project-kind";
14import { billing, deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
15import { getViewer, roleIn } from "../../lib/session.server";
16
17/** Projects whose open issues and pull requests are counted. */
18const MAX_COUNTED = 30;
19/** Projects whose pull requests are listed under "In progress". */
20const MAX_LISTED = 8;
21const MAX_PULLS = 8;
22const MAX_FACES = 8;
23/** The trial as published, when the price book cannot be read. */
24const DEFAULT_TRIAL_MICROS = 5_000_000;
25
26/**
27 * The month for the Usage card, for members only: what was spent, what
28 * pays first and what it went on. Fetched here, not with the sidebar, so
29 * only this page pays for it; a billing service that cannot answer leaves
30 * the card out rather than the page.
31 */
32async function usageFor(slug: string, viewer: User | null): Promise<UsageGlance | null> {
33 const now = new Date();
34 const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString();
35 const [account, usage, features, entitlements, limit, book] = await Promise.all([
36 billing.account(slug, viewer).catch(() => null),
37 billing.usage(slug, viewer, monthStart).catch(() => null),
38 billing.features(slug, viewer).catch(() => null),
39 billing.entitlements(slug).catch(() => null),
40 billing.limit(slug, viewer).catch(() => null),
41 billing.prices().catch(() => null),
42 ]);
43 if (!account?.ok || !usage?.ok) return null;
44 // Without payments set up (a g1t run without billing), there is no plan to show.
45 if (!account.value.status.enabled && !usage.value.free) return null;
46 const plan = features?.ok ? (features.value.find((state) => state.plan.feature === "plan") ?? null) : null;
47 return usageGlance({
48 usage: usage.value,
49 status: planStatus(plan, entitlements),
50 entitlements,
51 limit: limit?.ok ? limit.value : null,
52 trialMicros: book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS,
53 });
54}
55
56export async function loader({ params, context }: Route.LoaderArgs) {
57 const viewer = getViewer(context);
58 const slug = params.owner.toLowerCase();
59 const role = roleIn(viewer, slug);
60 const [listed, members, deploys, usage, published] = await Promise.all([
61 projectsApi.list(slug, viewer),
62 role ? identity.listMembers(slug, viewer) : null,
63 role ? deployments.overview(slug, viewer) : null,
64 role ? usageFor(slug, viewer) : null,
65 // One listing for every card: a library's card shows its package where an app's shows production.
66 packages.list(slug, viewer).catch(() => null),
67 ]);
68 const projects = listed.ok ? listed.value : [];
69
70 const pathOf = (project: Project) =>
71 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
72 const counted = projects.slice(0, MAX_COUNTED);
73 const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
74 const open: Record<string, { issues: number; pulls: number }> = {};
75 counted.forEach((project, i) => {
76 const result = counts[i];
77 if (result.ok) open[project.id] = result.value;
78 });
79
80 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
81 const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
82 const pulls = lists
83 .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
84 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
85 .slice(0, MAX_PULLS);
86
87 const bySlug: Record<string, ProjectDeploys> = {};
88 for (const entry of deploys?.ok ? deploys.value : []) bySlug[entry.slug] = entry;
89
90 const byRepo = new Map<string, PackageSummary[]>();
91 for (const pkg of published?.ok ? published.value : []) {
92 if (pkg.repo) byRepo.set(pkg.repo.id, [...(byRepo.get(pkg.repo.id) ?? []), pkg]);
93 }
94 const packageOf: Record<string, PackageSummary> = {};
95 for (const project of projects) {
96 if (project.kind !== "library" || project.source.kind !== "hosted") continue;
97 const first = libraryPackages(byRepo.get(project.source.repoId) ?? [])[0];
98 if (first) packageOf[project.id] = first;
99 }
100
101 return {
102 slug,
103 role,
104 projects,
105 open,
106 deploys: bySlug,
107 packageOf,
108 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
109 members: members?.ok ? members.value : null,
110 usage,
111 };
112}
113
114function Stat({ value, label }: { value: number; label: string }) {
115 return (
116 <div className="rounded-xl border border-line bg-surface px-4 py-3">
117 <p className="text-xl font-semibold tabular-nums tracking-tight">{value}</p>
118 <p className="text-xs text-muted">{label}</p>
119 </div>
120 );
121}
122
123/** What a member sees in a workspace with no projects yet. */
124function GetStarted({ slug }: { slug: string }) {
125 return (
126 <div className="rounded-2xl border border-line bg-surface p-8">
127 <span className="flex size-10 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/30">
128 <Box size={18} />
129 </span>
130 <h2 className="mt-4 text-lg font-semibold tracking-tight">Create your first project</h2>
131 <p className="mt-1.5 max-w-xl text-sm text-muted">
132 A project is what you build and run: its code, the issues that say what should change, the agents and
133 people who change it, and where it is deployed.
134 </p>
135 <div className="mt-6 flex flex-wrap gap-3">
136 <ButtonLink to={`/new?workspace=${slug}`} variant="accent">
137 <Plus size={14} />
138 New project
139 </ButtonLink>
140 </div>
141 <p className="mt-6 text-sm text-muted">Or push code you already have, and it becomes a project:</p>
142 <div className="mt-2 max-w-xl">
143 <CopyLine prompt text={`git push https://g1t.sh/${slug}/my-project.git main`} />
144 </div>
145 </div>
146 );
147}
148
149function ProjectCard({
150 project,
151 open,
152 deploys,
153 pkg,
154 member,
155}: {
156 project: Project;
157 open: { issues: number; pulls: number } | undefined;
158 deploys: ProjectDeploys | undefined;
159 /** For a library, the package it publishes. */
160 pkg: PackageSummary | undefined;
161 member: boolean;
162}) {
163 const library = project.kind === "library";
164 const base = `/${project.workspace}/${project.slug}`;
165 const production = deploys?.production ?? null;
166 const latest = deploys?.latest ?? null;
167 const source = project.source.kind === "hosted" ? project.source : null;
168 return (
169 <li className="group relative flex flex-col rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-line-strong">
170 <div className="flex items-start gap-3">
171 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-raised text-muted ring-1 ring-line">
172 {project.private ? <Lock size={15} /> : <Box size={15} />}
173 </span>
174 <div className="min-w-0 grow">
175 <Link to={base} prefetch="intent" className="font-medium after:absolute after:inset-0 hover:underline">
176 {project.name}
177 </Link>
178 {production ? (
179 <a
180 href={production.url}
181 className="relative z-10 mt-0.5 flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-accent"
182 >
183 {host(production.url)}
184 <ArrowUpRight size={11} className="shrink-0" />
185 </a>
186 ) : library && pkg ? (
187 <Link
188 to={packagePath(pkg)}
189 className="relative z-10 mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent"
190 >
191 {packageLine(pkg)}
192 </Link>
193 ) : (
194 <p className="mt-0.5 truncate text-xs text-faint">
195 {project.description ?? (library ? "Not published yet" : deploys?.enabled ? "Not deployed yet" : "Deployments are off")}
196 </p>
197 )}
198 </div>
199 {(project.private || project.archived) && (
200 <span className="flex shrink-0 gap-1.5">
201 {project.private && <Pill>private</Pill>}
202 {project.archived && <Pill>archived</Pill>}
203 </span>
204 )}
205 </div>
206
207 {project.description && (production || (library && pkg)) && <p className="mt-3 line-clamp-2 text-sm text-muted">{project.description}</p>}
208
209 <div className="mt-auto pt-5">
210 {member && latest && (
211 <p className="mb-3 flex items-center gap-2 text-xs text-muted">
212 <StatusDot status={latest.status} />
213 <span className="truncate">
214 {latest.kind === "production" ? "Production" : `Preview of ${latest.branch}`}
215 </span>
216 <span className="ml-auto shrink-0 text-faint">
217 <TimeAgo at={latest.createdAt} />
218 </span>
219 </p>
220 )}
221 <div className="flex items-center gap-4 border-t border-line pt-3 text-xs text-muted">
222 {source && (
223 <span className="flex min-w-0 items-center gap-1.5 font-mono">
224 <GitBranch size={12} className="shrink-0 text-faint" />
225 <span className="truncate">{source.defaultBranch}</span>
226 </span>
227 )}
228 <span className="flex items-center gap-1">
229 <CircleDot size={12} className="text-faint" />
230 {open?.issues ?? 0}
231 </span>
232 <span className="flex items-center gap-1">
233 <GitPullRequest size={12} className="text-faint" />
234 {open?.pulls ?? 0}
235 </span>
236 {member && deploys?.enabled && (deploys.previews ?? 0) > 0 && (
237 <span className="ml-auto flex items-center gap-1 text-accent">
238 <Rocket size={12} />
239 {deploys.previews} {deploys.previews === 1 ? "preview" : "previews"}
240 </span>
241 )}
242 </div>
243 </div>
244 </li>
245 );
246}
247
248export default function WorkspaceOverview({ loaderData }: Route.ComponentProps) {
249 const { slug, role, projects, open, deploys, pulls, members, usage } = loaderData;
250 const totals = Object.values(open).reduce(
251 (sum, counts) => ({ issues: sum.issues + counts.issues, pulls: sum.pulls + counts.pulls }),
252 { issues: 0, pulls: 0 },
253 );
254 const liveApps = Object.values(deploys).filter((entry) => entry.production).length;
255 return (
256 <div className="grid gap-10 lg:grid-cols-[1fr_18rem]">
257 <div className="min-w-0 space-y-10">
258 {projects.length === 0 && role ? (
259 <GetStarted slug={slug} />
260 ) : (
261 <>
262 <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
263 <Stat value={projects.length} label={projects.length === 1 ? "Project" : "Projects"} />
264 <Stat value={liveApps} label="In production" />
265 <Stat value={totals.issues} label="Open issues" />
266 <Stat value={totals.pulls} label="Pull requests in progress" />
267 </div>
268
269 <section>
270 <div className="flex items-center justify-between">
271 <h2 className="text-sm font-medium text-muted">Projects</h2>
272 {role && (
273 <Link to={`/new?workspace=${slug}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
274 <Plus size={13} />
275 New project
276 </Link>
277 )}
278 </div>
279 <ul className="mt-3 grid gap-4 sm:grid-cols-2">
280 {projects.map((project) => (
281 <ProjectCard
282 key={project.id}
283 project={project}
284 open={open[project.id]}
285 deploys={deploys[project.slug]}
286 pkg={loaderData.packageOf[project.id]}
287 member={role != null}
288 />
289 ))}
290 </ul>
291 </section>
292
293 {pulls.length > 0 && (
294 <section>
295 <h2 className="text-sm font-medium text-muted">In progress</h2>
296 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
297 {pulls.map(({ pull, slug: project, name }) => (
298 <li key={pull.id}>
299 <Link
300 prefetch="intent"
301 to={`/${slug}/${project}/pull/${pull.number}`}
302 className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-raised"
303 >
304 <PullIcon status={pull.status} />
305 <span className="min-w-0 grow">
306 <span className="block truncate font-medium">{pull.title}</span>
307 <span className="text-xs text-muted">
308 {name} <span className="font-mono">#{pull.number}</span>
309 {pull.issue != null && ` · for #${pull.issue}`} · {openedBy(pull).name}
310 </span>
311 </span>
312 <span className="hidden shrink-0 text-xs text-muted sm:block">
313 {pull.status === "draft" ? "Being worked on" : "Ready for review"}
314 </span>
315 <span className="w-14 shrink-0 text-right text-xs text-faint">
316 <TimeAgo at={pull.updatedAt} />
317 </span>
318 </Link>
319 </li>
320 ))}
321 </ul>
322 </section>
323 )}
324 </>
325 )}
326 </div>
327
328 <aside className="space-y-6">
329 {role && usage && <UsageCard slug={slug} glance={usage} owner={role === "owner"} />}
330
331 {members ? (
332 <section>
333 <div className="flex items-center justify-between">
334 <h2 className="text-sm font-medium">Members</h2>
335 <Link to={`/${slug}/-/people`} className="text-xs text-muted hover:text-fg">
336 {role === "owner" ? "Manage" : "See all"}
337 </Link>
338 </div>
339 <ul className="mt-3 space-y-1.5">
340 {members.slice(0, MAX_FACES).map((member) => (
341 <li key={member.username} className="flex items-center gap-2 text-sm">
342 <Avatar name={member.username} />
343 <span className="grow truncate font-mono">{member.username}</span>
344 {member.role === "owner" && <Pill>owner</Pill>}
345 </li>
346 ))}
347 </ul>
348 {members.length > MAX_FACES && <p className="mt-2 text-xs text-faint">and {members.length - MAX_FACES} more</p>}
349 </section>
350 ) : (
351 <p className="text-sm text-muted">
352 Members of this workspace build its projects. Anyone can open an issue or a pull request on a public one.
353 </p>
354 )}
355
356 {role && (
357 <section className="rounded-xl border border-line bg-surface p-5">
358 <h2 className="flex items-center gap-2 font-medium">
359 <Rocket size={15} className="text-faint" />
360 Deploy on g1t.page
361 </h2>
362 <p className="mt-1.5 text-sm text-muted">
363 Off until you turn them on for a project, under its Settings. Then production builds from its default
364 branch, with a live preview for every pull request. Apps cost nothing while no one visits.
365 </p>
366 <Link
367 prefetch="intent"
368 to={`/${slug}/-/billing`}
369 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
370 >
371 Billing and plans <ArrowRight size={13} />
372 </Link>
373 </section>
374 )}
375
376 {role && (
377 <section className="rounded-xl border border-line bg-surface p-5">
378 <h2 className="flex items-center gap-2 font-medium">
379 <KeyRound size={15} className="text-faint" />
380 Automate without a service account
381 </h2>
382 <p className="mt-1.5 text-sm text-muted">
383 A workspace has access tokens of its own for CI, integrations and agents. They act as the workspace,
384 not as a person.
385 </p>
386 <Link
387 prefetch="intent"
388 to={`/${slug}/-/tokens`}
389 className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
390 >
391 Access tokens <ArrowRight size={13} />
392 </Link>
393 </section>
394 )}
395 </aside>
396 </div>
397 );
398}