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.

g1t.sh
509 linesCodeBlameRaw
1/**
2 * The pieces mission control and a project's overview are made of: panels
3 * with a "View all", what needs the viewer, the activity feed with its
4 * filters, the week's pulse with its sparklines, a project's pipeline and
5 * its deploy history.
6 */
7import {
8 ArrowRight,
9 CircleCheck,
10 CircleDot,
11 CircleSlash,
12 Eye,
13 GitCommitHorizontal,
14 GitMerge,
15 GitPullRequest,
16 Hand,
17 MessageSquare,
18 Play,
19 Rocket,
20 Swords,
21 Terminal,
22 TimerOff,
23 TriangleAlert,
24 UserPlus,
25} from "lucide-react";
26import { type ReactNode, useMemo, useState } from "react";
27import { Link } from "react-router";
28
29import type { DeployStatus } from "@g1t/contracts";
30
31import { cn } from "../lib/cn";
32import { type ActivityGroup, type Need, type NeedKind, type Verb, isAgent, sparkPoints } from "../lib/mission";
33import { TimeAgo } from "./ui";
34import { Avatar } from "./ui/avatar";
35import { UserAvatar } from "./user-card";
36import { PersonLink } from "./work";
37import { Button } from "./ui/button";
38import { Card } from "./ui/card";
39import { Hint } from "./ui/hint";
40import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
41
42// --- Panels -------------------------------------------------------------------
43
44/** A titled section, with a count and a link to everything it summarises. */
45export function Panel({
46 id,
47 title,
48 icon,
49 count,
50 all,
51 extra,
52 children,
53 className,
54}: {
55 id?: string;
56 title: ReactNode;
57 icon?: ReactNode;
58 count?: number | null;
59 all?: { to: string; label?: string } | null;
60 extra?: ReactNode;
61 children: ReactNode;
62 className?: string;
63}) {
64 return (
65 <section id={id} className={cn("scroll-mt-20", className)}>
66 <div className="flex min-h-7 flex-wrap items-center gap-x-3 gap-y-2">
67 <h2 className="flex items-center gap-2 text-sm font-semibold tracking-tight">
68 {icon && <span className="text-faint">{icon}</span>}
69 {title}
70 {count != null && count > 0 && (
71 <span className="rounded-full bg-line px-1.5 text-[0.6875rem] font-medium tabular-nums text-muted">{count}</span>
72 )}
73 </h2>
74 <span className="grow" />
75 {extra}
76 {all && (
77 <Link to={all.to} prefetch="intent" className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
78 {all.label ?? "View all"}
79 <ArrowRight size={12} />
80 </Link>
81 )}
82 </div>
83 <div className="mt-3">{children}</div>
84 </section>
85 );
86}
87
88/** Says a section could not be loaded, without taking the page down. */
89export function Unavailable({ what }: { what: string }) {
90 return (
91 <Card asChild tone="plain" className="flex items-center gap-2 border-dashed px-4 py-5 text-sm text-muted">
92 <p>
93 <TriangleAlert size={14} className="text-warn" />
94 {what} could not be loaded just now. It will be back on the next refresh.
95 </p>
96 </Card>
97 );
98}
99
100/** A quiet, dashed empty state with an optional call to action. */
101export function Quiet({ children, action }: { children: ReactNode; action?: ReactNode }) {
102 return (
103 <Card tone="plain" className="flex flex-wrap items-center gap-3 border-dashed px-4 py-5 text-sm text-muted">
104 <span className="min-w-0 grow">{children}</span>
105 {action}
106 </Card>
107 );
108}
109
110// --- Needs you ----------------------------------------------------------------
111
112const NEED: Record<NeedKind, { icon: ReactNode; tone: string; label: string }> = {
113 deploy: { icon: <Rocket size={15} />, tone: "text-danger", label: "Production" },
114 invitation: { icon: <UserPlus size={15} />, tone: "text-accent", label: "Invitation" },
115 conflict: { icon: <Swords size={15} />, tone: "text-warn", label: "Conflict" },
116 stalled: { icon: <Hand size={15} />, tone: "text-warn", label: "Stopped" },
117 stuck: { icon: <TimerOff size={15} />, tone: "text-warn", label: "Quiet run" },
118 runner: { icon: <TimerOff size={15} />, tone: "text-warn", label: "No runner" },
119 review: { icon: <Eye size={15} />, tone: "text-info", label: "Review" },
120 checks: { icon: <Terminal size={15} />, tone: "text-danger", label: "Checks" },
121 ready: { icon: <GitMerge size={15} />, tone: "text-success", label: "Ready" },
122};
123
124/** What is waiting on the viewer, most urgent first, each with its next step. */
125export function NeedsList({ needs, limit = 8 }: { needs: Need[]; limit?: number }) {
126 const [all, setAll] = useState(false);
127 const shown = all ? needs : needs.slice(0, limit);
128 return (
129 <>
130 <Card asChild divided className="overflow-hidden">
131 <ul>
132 {shown.map((need) => {
133 const look = NEED[need.kind];
134 return (
135 <li key={need.key} className="flex items-start gap-3 px-4 py-3">
136 <Hint label={look.label}>
137 <span className={cn("mt-0.5 shrink-0", look.tone)}>
138 {look.icon}
139 <span className="sr-only">{look.label}</span>
140 </span>
141 </Hint>
142 <span className="min-w-0 grow">
143 <Link to={need.to} prefetch="intent" className="block truncate text-sm font-medium hover:text-accent">
144 {need.title}
145 </Link>
146 <span className="mt-0.5 block text-xs leading-5 text-muted">
147 {need.where && <span className="font-mono text-faint">{need.where} · </span>}
148 {need.detail}
149 </span>
150 </span>
151 <span className="hidden shrink-0 pt-0.5 text-xs text-faint sm:block">
152 <TimeAgo at={need.at} />
153 </span>
154 <Link
155 to={need.to}
156 prefetch="intent"
157 className="shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/85 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"
158 >
159 {need.action}
160 </Link>
161 </li>
162 );
163 })}
164 </ul>
165 </Card>
166 {needs.length > limit && (
167 <Button type="button" onClick={() => setAll(!all)} variant="link" size="inline" className="mt-2 text-xs text-muted hover:text-fg font-normal">
168 {all ? "Show fewer" : `Show ${needs.length - limit} more`}
169 </Button>
170 )}
171 </>
172 );
173}
174
175// --- Activity -----------------------------------------------------------------
176
177const VERB: Record<Verb, { icon: ReactNode; tone: string; text: (n: string) => string }> = {
178 landed: { icon: <GitMerge size={14} />, tone: "text-merged", text: (n) => `landed ${n}` },
179 opened_issue: { icon: <CircleDot size={14} />, tone: "text-success", text: (n) => `opened ${n}` },
180 closed_issue: { icon: <CircleSlash size={14} />, tone: "text-faint", text: (n) => `closed ${n}` },
181 started: { icon: <Play size={14} />, tone: "text-accent", text: (n) => `started on ${n}` },
182 ready: { icon: <GitPullRequest size={14} />, tone: "text-info", text: (n) => `marked ${n} ready for review` },
183 checks_passed: { icon: <Terminal size={14} />, tone: "text-success", text: (n) => `checks passed on ${n}` },
184 checks_failed: { icon: <Terminal size={14} />, tone: "text-danger", text: (n) => `checks failed on ${n}` },
185 approved: { icon: <CircleCheck size={14} />, tone: "text-success", text: (n) => `approved ${n}` },
186 changes_requested: { icon: <CircleSlash size={14} />, tone: "text-warn", text: (n) => `asked for changes on ${n}` },
187 commented: { icon: <MessageSquare size={14} />, tone: "text-muted", text: (n) => `commented on ${n}` },
188 deployed: { icon: <Rocket size={14} />, tone: "text-success", text: () => "deployed production" },
189 deploy_failed: { icon: <Rocket size={14} />, tone: "text-danger", text: () => "production build failed" },
190 pushed: { icon: <GitCommitHorizontal size={14} />, tone: "text-muted", text: () => "pushed to the default branch" },
191};
192
193function Refs({ numbers, base }: { numbers: number[]; base: string }) {
194 const shown = numbers.slice(0, 4);
195 return (
196 <>
197 {shown.map((number, index) => (
198 <span key={number}>
199 {index > 0 && (index === shown.length - 1 && numbers.length <= 4 ? " and " : ", ")}
200 <Link to={`${base}/issues/${number}`} prefetch="intent" className="font-medium text-fg hover:underline">
201 #{number}
202 </Link>
203 </span>
204 ))}
205 {numbers.length > 4 && ` and ${numbers.length - 4} more`}
206 </>
207 );
208}
209
210/** One group as a sentence: "landed #4 and #3; started on #5". */
211function GroupLine({ group }: { group: ActivityGroup }) {
212 const base = `/${group.repo.namespace}/${group.repo.name}`;
213 return (
214 <>
215 {group.parts.map((part, index) => {
216 const look = VERB[part.verb];
217 const [before, after] = look.text("\u0000").split("\u0000");
218 return (
219 <span key={part.verb}>
220 {index > 0 && "; "}
221 {part.numbers.length > 0 ? (
222 <>
223 {before}
224 <Refs numbers={part.numbers} base={base} />
225 {after}
226 </>
227 ) : part.to ? (
228 <Link to={part.to} className="hover:text-fg">
229 {look.text("")}
230 </Link>
231 ) : (
232 look.text("")
233 )}
234 </span>
235 );
236 })}
237 </>
238 );
239}
240
241/**
242 * What moved across projects, newest first, a burst of one actor's work
243 * as one line, filterable by project and by who did it.
244 */
245export function ActivityFeed({
246 groups,
247 showRepo = true,
248 limit = 14,
249 empty,
250}: {
251 groups: ActivityGroup[];
252 showRepo?: boolean;
253 limit?: number;
254 empty: ReactNode;
255}) {
256 const [project, setProject] = useState("all");
257 const [person, setPerson] = useState("all");
258 const [more, setMore] = useState(false);
259 const projects = useMemo(
260 () => [...new Set(groups.map((g) => `${g.repo.namespace}/${g.repo.name}`))].sort(),
261 [groups],
262 );
263 const people = useMemo(
264 () => [...new Set(groups.map((g) => g.actor).filter((a): a is string => a != null))].sort(),
265 [groups],
266 );
267 const filtered = groups.filter(
268 (g) =>
269 (project === "all" || `${g.repo.namespace}/${g.repo.name}` === project) &&
270 (person === "all" || g.actor === person),
271 );
272 const shown = more ? filtered : filtered.slice(0, limit);
273 return (
274 <div>
275 {((showRepo && projects.length > 1) || people.length > 1) && (
276 <div className="mb-3 flex flex-wrap items-center gap-2">
277 {showRepo && projects.length > 1 && (
278 <Select value={project} onValueChange={setProject}>
279 <SelectTrigger size="sm" className="w-auto max-w-56" aria-label="Project">
280 <SelectValue />
281 </SelectTrigger>
282 <SelectContent>
283 <SelectItem value="all">All projects</SelectItem>
284 {projects.map((name) => (
285 <SelectItem key={name} value={name}>
286 {name.split("/")[1]}
287 </SelectItem>
288 ))}
289 </SelectContent>
290 </Select>
291 )}
292 {people.length > 1 && (
293 <Select value={person} onValueChange={setPerson}>
294 <SelectTrigger size="sm" className="w-auto max-w-48" aria-label="Who">
295 <SelectValue />
296 </SelectTrigger>
297 <SelectContent>
298 <SelectItem value="all">Anyone</SelectItem>
299 {people.map((name) => (
300 <SelectItem key={name} value={name} icon={<Avatar name={name} image={isAgent(name) ? null : undefined} size={16} />}>
301 {name}
302 </SelectItem>
303 ))}
304 </SelectContent>
305 </Select>
306 )}
307 </div>
308 )}
309 {shown.length === 0 ? (
310 <Quiet>{groups.length === 0 ? empty : "Nothing matches these filters."}</Quiet>
311 ) : (
312 <ol className="relative space-y-0.5 before:absolute before:top-3 before:bottom-3 before:left-3.75 before:w-px before:bg-line">
313 {shown.map((group) => {
314 const look = VERB[group.parts[0].verb];
315 const agent = group.actor == null || isAgent(group.actor);
316 return (
317 <li key={group.id} className="relative flex items-start gap-3 rounded-lg px-1 py-1.5 text-sm">
318 <span
319 className={cn(
320 "relative z-10 mt-0.5 flex size-5.5 shrink-0 items-center justify-center rounded-full bg-bg ring-1 ring-line",
321 look.tone,
322 )}
323 >
324 {look.icon}
325 </span>
326 <span className="min-w-0 grow leading-6 text-muted">
327 <span className={cn("mr-1.5 inline-flex items-center gap-1.5 align-middle font-medium", agent ? "text-accent" : "text-fg")}>
328 {agent ? (
329 <>
330 <Avatar name={group.actor ?? "g1t"} image={null} size={16} />
331 {group.actor ?? "g1t"}
332 </>
333 ) : (
334 <>
335 <UserAvatar username={group.actor!} size={16} />
336 <PersonLink name={group.actor!} className="hover:text-accent" />
337 </>
338 )}
339 </span>
340 <GroupLine group={group} />
341 {showRepo && (
342 <Link
343 to={`/${group.repo.namespace}/${group.repo.name}`}
344 className="ml-1.5 font-mono text-xs text-faint hover:text-muted"
345 >
346 {group.repo.name}
347 </Link>
348 )}
349 {group.count > 2 && <span className="ml-1.5 text-xs text-faint">· {group.count} events</span>}
350 </span>
351 <span className="shrink-0 pt-0.5 text-xs text-faint">
352 <TimeAgo at={group.at} />
353 </span>
354 </li>
355 );
356 })}
357 </ol>
358 )}
359 {filtered.length > limit && (
360 <Button type="button" onClick={() => setMore(!more)} variant="link" size="inline" className="mt-2 text-xs text-muted hover:text-fg font-normal">
361 {more ? "Show fewer" : `Show ${filtered.length - limit} more`}
362 </Button>
363 )}
364 </div>
365 );
366}
367
368// --- Pulse --------------------------------------------------------------------
369
370const DAY_LABEL = (offset: number) => {
371 const date = new Date(Date.now() - offset * 86_400_000);
372 return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" });
373};
374
375/**
376 * A week of one measure as a small line, the last day marked. Each day
377 * has a hover target that says its value.
378 */
379export function Sparkline({
380 values,
381 format,
382 width = 96,
383 height = 28,
384}: {
385 values: number[];
386 format: (value: number) => string;
387 width?: number;
388 height?: number;
389}) {
390 const points = sparkPoints(values, width, height, 3);
391 if (points.length === 0) return null;
392 const line = points.map(([x, y], i) => `${i ? "L" : "M"}${x},${y}`).join(" ");
393 const area = `${line} L${points[points.length - 1][0]},${height} L${points[0][0]},${height} Z`;
394 const [lastX, lastY] = points[points.length - 1];
395 const slot = width / values.length;
396 return (
397 <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible text-accent" role="img" aria-label={values.map(format).join(", ")}>
398 <path d={area} fill="currentColor" opacity={0.12} />
399 <path d={line} fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
400 <circle cx={lastX} cy={lastY} r={3} fill="currentColor" stroke="var(--color-surface)" strokeWidth={2} />
401 {values.map((value, index) => (
402 <rect key={index} x={index * slot} y={0} width={slot} height={height} fill="transparent" className="hover:fill-fg/5">
403 <title>{`${DAY_LABEL(values.length - 1 - index)}: ${format(value)}`}</title>
404 </rect>
405 ))}
406 </svg>
407 );
408}
409
410/** One measure of the week: a headline number, what it means, and its line. */
411export function PulseTile({
412 label,
413 value,
414 hint,
415 values,
416 format,
417 to,
418}: {
419 label: string;
420 value: string;
421 hint?: string;
422 values?: number[];
423 format?: (value: number) => string;
424 to?: string;
425}) {
426 const body = (
427 <>
428 <p className="truncate text-xs text-muted">{label}</p>
429 <div className="mt-1 flex items-end justify-between gap-2">
430 <p className="text-2xl font-semibold tracking-tight tabular-nums">{value}</p>
431 {values && format && values.some((v) => v > 0) && (
432 <span className="mb-1">
433 <Sparkline values={values} format={format} width={56} height={22} />
434 </span>
435 )}
436 </div>
437 <Hint label={hint}>
438 <p className="mt-1.5 truncate text-[0.6875rem] text-faint">{hint}</p>
439 </Hint>
440 </>
441 );
442 const box = "block rounded-xl border border-line bg-surface p-4 transition-colors";
443 return to ? (
444 <Link to={to} className={`${box} hover:border-line-strong`}>
445 {body}
446 </Link>
447 ) : (
448 <div className={box}>{body}</div>
449 );
450}
451
452// --- Deploys ------------------------------------------------------------------
453
454const STRIP_TONE: Record<DeployStatus, string> = {
455 ready: "bg-success",
456 // Served once, until a newer build or a take-down: it worked.
457 replaced: "bg-success/45",
458 down: "bg-line-strong",
459 failed: "bg-danger",
460 building: "bg-warn animate-pulse",
461 queued: "bg-line-strong animate-pulse",
462 skipped: "bg-line",
463};
464
465/** The last builds as a row of bars, oldest on the left; each links to its build. */
466export function DeployStrip({
467 builds,
468 base,
469 slots = 20,
470}: {
471 builds: { id: string; status: DeployStatus; kind: string; commit: string; createdAt: string }[];
472 base: string;
473 slots?: number;
474}) {
475 const shown = builds.slice(0, slots).reverse();
476 return (
477 <div className="flex h-8 items-end gap-0.75" aria-label="Recent builds, oldest first">
478 {Array.from({ length: slots - shown.length }, (_, i) => (
479 <span key={`empty-${i}`} className="h-2 flex-1 rounded-sm bg-line/60" />
480 ))}
481 {shown.map((build) => {
482 const about = `${build.kind === "production" ? "Production" : "Preview"} · ${build.commit.slice(0, 7)} · ${build.status} · ${new Date(build.createdAt).toLocaleString("en-US")}`;
483 return (
484 <Hint key={build.id} label={about}>
485 <Link
486 to={`${base}/deployments/${build.id}`}
487 aria-label={about}
488 className={cn("flex-1 rounded-sm transition-opacity hover:opacity-80", STRIP_TONE[build.status], build.kind === "production" ? "h-8" : "h-5")}
489 />
490 </Hint>
491 );
492 })}
493 </div>
494 );
495}
496
497/** A small meter for a share, 0 to 1. */
498export function Meter({ value, tone = "bg-success" }: { value: number | null; tone?: string }) {
499 return (
500 <span className="block h-1.5 w-full overflow-hidden rounded-full bg-line">
501 {value != null && <span className={cn("block h-full rounded-full", tone)} style={{ width: `${Math.round(value * 100)}%` }} />}
502 </span>
503 );
504}
505
506export function percent(value: number | null): string {
507 return value == null ? "—" : `${Math.round(value * 100)}%`;
508}
509