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.

189 linesCodeBlameRaw
1import { ChevronRight, UserRound } from "lucide-react";
2import { Link } from "react-router";
3
4import type { AdminAction } from "@g1t/contracts";
5
6import type { Route } from "./+types/audit";
7import { Button, EmptyState, Input, Notice, PageHeader, Select, When } from "~/components/ui";
8import {
9 AUDIT_ACTIONS,
10 AUDIT_PAGE,
11 accountName,
12 accountPath,
13 actionLabel,
14 auditHref,
15 mergeAudit,
16 olderBefore,
17 parseAction,
18 parseBefore,
19 parseBy,
20 statusAuditActions,
21} from "~/lib/ledgers";
22import { admin, statusAdmin } from "~/lib/services.server";
23import { settle } from "~/lib/settle";
24import { requireStaff } from "~/lib/staff";
25
26export const meta: Route.MetaFunction = () => [{ title: "Audit log · sudo" }, { name: "robots", content: "noindex, nofollow" }];
27
28export async function loader({ request, context }: Route.LoaderArgs) {
29 const staff = requireStaff(context);
30 const url = new URL(request.url);
31 const by = parseBy(url.searchParams.get("by"));
32 const action = parseAction(url.searchParams.get("action"));
33 const before = parseBefore(url.searchParams.get("before"));
34 // Enterprises' names, so a line about one reads as its name, not its id.
35 // The status page keeps its own log (incidents, maintenance, postmortems), merged in here.
36 const [result, accounts, status] = await Promise.all([
37 settle(admin.audit({ by: by ?? undefined, action: action ?? undefined, before: before ?? undefined })),
38 settle(admin.accounts()),
39 settle(statusAdmin.audit({ before })),
40 ]);
41 const names = Object.fromEntries(
42 (accounts.ok ? accounts.value : []).filter((row) => row.account.kind === "enterprise").map((row) => [row.account.id, row.account.name]),
43 );
44 const fromStatus = statusAuditActions(status.ok ? status.value : []).filter(
45 (entry) => (!by || entry.by.toLowerCase() === by) && (!action || entry.action === action),
46 );
47 const actions = mergeAudit(result.ok ? result.value : [], fromStatus);
48 return {
49 me: staff.email,
50 by,
51 action,
52 before,
53 actions,
54 names,
55 older: olderBefore(actions),
56 error: result.ok ? null : result.error,
57 };
58}
59
60/** Where a change came from: Stripe's lines are about Stripe, not a person. */
61function isStripe(entry: AdminAction) {
62 return entry.action === "stripe" || entry.action === "webhook" || entry.by === "stripe";
63}
64
65export default function Audit({ loaderData }: Route.ComponentProps) {
66 const { me, by, action, before, actions, names, older, error } = loaderData;
67 const nameMap = new Map(Object.entries(names));
68 const filtered = by != null || action != null;
69
70 return (
71 <main className="mx-auto max-w-6xl px-4 py-8 sm:py-10">
72 <PageHeader
73 title="Audit log"
74 description="Every change made in sudo, what Stripe told billing, and every incident and maintenance change on the status page, newest first. Each names who made it."
75 />
76
77 <form method="get" action="/audit" className="mt-6 flex flex-col gap-2 sm:flex-row sm:items-center">
78 <Input
79 name="by"
80 type="search"
81 defaultValue={by ?? ""}
82 placeholder="Staff email, such as you@g1t.sh"
83 aria-label="Made by"
84 className="font-mono text-xs sm:w-72"
85 />
86 <Select name="action" defaultValue={action ?? ""} aria-label="Kind of change" className="sm:w-60">
87 <option value="">Every kind of change</option>
88 {Object.entries(AUDIT_ACTIONS).map(([value, label]) => (
89 <option key={value} value={value}>
90 {label}
91 </option>
92 ))}
93 </Select>
94 <div className="flex items-center gap-2">
95 <Button type="submit" variant="quiet" className="py-2">
96 Apply
97 </Button>
98 <Link to={auditHref({ by: me })} className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
99 Mine
100 </Link>
101 {filtered && (
102 <Link to="/audit" className="px-1 text-xs text-muted underline-offset-4 hover:text-fg hover:underline">
103 Clear
104 </Link>
105 )}
106 </div>
107 </form>
108
109 {before && (
110 <p className="mt-4 text-xs text-faint">
111 Changes before <When at={before} time />.{" "}
112 <Link to={auditHref({ by, action })} className="text-accent hover:underline">
113 Back to the newest
114 </Link>
115 </p>
116 )}
117
118 {error ? (
119 <div className="mt-4">
120 <Notice tone="warn">Billing did not answer for the audit log: {error}</Notice>
121 </div>
122 ) : actions.length === 0 ? (
123 <div className="mt-4">
124 <EmptyState title={filtered || before ? "No changes match" : "No changes yet"}>
125 {filtered ? "Try another staff email or kind of change, or clear the filters." : "Changes appear here as staff make them."}
126 </EmptyState>
127 </div>
128 ) : (
129 <ol className="mt-4 divide-y divide-line rounded-lg border border-line bg-surface">
130 {actions.map((entry) => {
131 const path = accountPath(entry.account);
132 const stripe = isStripe(entry);
133 return (
134 <li key={entry.id} className="flex flex-col gap-1.5 px-4 py-3 sm:flex-row sm:items-start sm:gap-4 sm:px-5">
135 <p className="shrink-0 text-xs whitespace-nowrap text-faint sm:w-44 sm:pt-0.5">
136 <When at={entry.createdAt} time />
137 </p>
138 <div className="min-w-0 grow">
139 <p className="flex flex-wrap items-center gap-x-2 text-sm">
140 <span className={`font-medium ${stripe ? "text-info" : "text-accent"}`}>{actionLabel(entry.action)}</span>
141 {path && (
142 <Link to={path} className="inline-flex items-center gap-0.5 text-fg-soft hover:text-fg hover:underline hover:underline-offset-4">
143 <span className={entry.account.startsWith("ws_") ? "font-mono text-xs" : ""}>{accountName(entry.account, nameMap)}</span>
144 <ChevronRight size={12} className="text-faint" />
145 </Link>
146 )}
147 </p>
148 {entry.detail && <p className="mt-0.5 text-sm wrap-break-word text-muted">{entry.detail}</p>}
149 </div>
150 <p className="flex shrink-0 items-center gap-1 text-xs text-faint sm:w-56 sm:justify-end sm:pt-0.5">
151 {stripe && entry.by === "stripe" ? (
152 "Stripe"
153 ) : (
154 <Link
155 to={auditHref({ by: entry.by, action })}
156 title={`Every change by ${entry.by}`}
157 className="inline-flex min-w-0 items-center gap-1 hover:text-fg"
158 >
159 <UserRound size={11} className="shrink-0" />
160 <span className="truncate font-mono">{entry.by.toLowerCase() === me.toLowerCase() ? "you" : entry.by}</span>
161 </Link>
162 )}
163 </p>
164 </li>
165 );
166 })}
167 </ol>
168 )}
169
170 {(older || actions.length > 0) && !error && (
171 <nav aria-label="Pages" className="mt-4 flex items-center justify-between gap-3 text-sm">
172 <span className="text-xs text-faint">
173 {actions.length} change{actions.length === 1 ? "" : "s"}
174 {older ? `, ${AUDIT_PAGE} a page` : ""}
175 </span>
176 {older && (
177 <Link
178 to={auditHref({ by, action, before: older })}
179 className="inline-flex items-center gap-1 rounded-md border border-line px-3 py-1.5 text-muted hover:border-line-strong hover:text-fg"
180 >
181 Older
182 <ChevronRight size={14} />
183 </Link>
184 )}
185 </nav>
186 )}
187 </main>
188 );
189}