flagon-io/g1t

public

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/apps/web/app/routes/workspace/audit.tsx

187 lines7,145 bytesCodeBlame
1import { Download, Filter } from "lucide-react";
2import { Form, Link, data } from "react-router";
3
4import type { Route } from "./+types/audit";
5import { page } from "../../lib/meta";
6import { AuditTable } from "../../components/audit";
7import { Button, ButtonLink, EmptyState, Field, Input } from "../../components/ui";
8import { type AuditFilters, filterHref, parseFilters, toQuery } from "../../lib/audit";
9import { auditPage } from "../../lib/audit.server";
10import { repos } from "../../lib/services.server";
11import { requireUser, roleIn } from "../../lib/session.server";
12
13const PAGE_SIZE = 100;
14
15/** Actions worth offering in the filter; any other can be typed. */
16const COMMON_ACTIONS = [
17 "git.push",
18 "git.fetch",
19 "create_issue",
20 "add_comment",
21 "record_session",
22 "mark_pull_request_ready",
23 "review_pull_request",
24 "merge_pull_request",
25 "remember",
26 "message_agent",
27];
28
29export function meta({ params, ...args }: Route.MetaArgs) {
30 return page(args, { title: `Audit log · ${params.owner} · g1t` });
31}
32
33export async function loader({ params, context, request }: Route.LoaderArgs) {
34 const viewer = requireUser(context, request);
35 const workspace = params.owner.toLowerCase();
36 const role = roleIn(viewer, workspace);
37 if (!role) throw data("Only members of this workspace can read its audit log.", { status: 404 });
38 const filters = parseFilters(new URL(request.url).searchParams);
39 const { visibility: _, ...query } = toQuery(workspace, { kind: "all" }, filters, PAGE_SIZE);
40 const [found, projects] = await Promise.all([
41 auditPage(viewer, query),
42 repos.list(viewer, { namespace: workspace }).catch(() => []),
43 ]);
44 return {
45 workspace,
46 role,
47 filters,
48 entries: found?.entries ?? [],
49 next: found?.next ?? null,
50 projects: projects.map((repo) => repo.name),
51 };
52}
53
54function FilterField({
55 label,
56 name,
57 value,
58 placeholder,
59 list,
60}: {
61 label: string;
62 name: keyof AuditFilters;
63 value: string;
64 placeholder?: string;
65 list?: string;
66}) {
67 return (
68 <Field label={label}>
69 <Input name={name} defaultValue={value} placeholder={placeholder} list={list} />
70 </Field>
71 );
72}
73
74const SELECT =
75 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors hover:border-line-strong focus:border-accent-dim";
76
77export default function WorkspaceAudit({ loaderData }: Route.ComponentProps) {
78 const { workspace, role, filters, entries, next, projects } = loaderData;
79 const base = `/${workspace}/-/audit`;
80 const filtered = Object.entries(filters).some(([key, value]) => key !== "before" && value);
81 const exportHref = (format: "csv" | "json") => filterHref(`${base}/export`, { ...filters, before: "" }) + `${filtered ? "&" : "?"}format=${format}`;
82 return (
83 <div>
84 <p className="max-w-3xl text-sm text-muted">
85 Every action taken with an agent run's credentials, reads included, and every change people and
86 workspace tokens make through the API, MCP and git: who did it, on whose behalf, with which
87 credential, to what, and whether it was allowed. Refusals name the rule that refused them.
88 {role === "owner"
89 ? " As an owner you see the whole workspace."
90 : " As a member you see what was done to the workspace's projects, and what was done by you or on your behalf."}
91 </p>
92
93 <Form method="get" className="mt-6 rounded-xl border border-line bg-surface p-4">
94 <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
95 <FilterField label="Actor" name="actor" value={filters.actor} placeholder="A person, or whom an agent worked for" />
96 <FilterField label="Agent" name="agent" value={filters.agent} placeholder="g1t-agent" />
97 <FilterField label="Action" name="action" value={filters.action} placeholder="git.push" list="audit-actions" />
98 <FilterField label="Project" name="project" value={filters.project} placeholder="Any" list="audit-projects" />
99 <Field label="Outcome">
100 <select name="outcome" defaultValue={filters.outcome} className={SELECT}>
101 <option value="">Any</option>
102 <option value="allowed">Allowed</option>
103 <option value="denied">Denied</option>
104 </select>
105 </Field>
106 <Field label="Who">
107 <select name="kind" defaultValue={filters.kind} className={SELECT}>
108 <option value="">Anyone</option>
109 <option value="agent">Agents</option>
110 <option value="person">People</option>
111 <option value="workspace">Workspace tokens</option>
112 </select>
113 </Field>
114 <Field label="From">
115 <Input type="date" name="from" defaultValue={filters.from} />
116 </Field>
117 <Field label="To">
118 <Input type="date" name="to" defaultValue={filters.to} />
119 </Field>
120 </div>
121 {filters.run && <input type="hidden" name="run" value={filters.run} />}
122 <datalist id="audit-actions">
123 {COMMON_ACTIONS.map((action) => (
124 <option key={action} value={action} />
125 ))}
126 </datalist>
127 <datalist id="audit-projects">
128 {projects.map((name) => (
129 <option key={name} value={name} />
130 ))}
131 </datalist>
132 <div className="mt-4 flex flex-wrap items-center gap-3">
133 <Button type="submit">
134 <Filter size={14} />
135 Filter
136 </Button>
137 {filtered && (
138 <Link to={base} className="text-sm text-muted hover:text-fg">
139 Clear filters
140 </Link>
141 )}
142 {filters.run && (
143 <span className="font-mono text-xs text-muted">
144 Run {filters.run}
145 </span>
146 )}
147 <span className="grow" />
148 <ButtonLink variant="quiet" to={exportHref("csv")} reloadDocument>
149 <Download size={14} />
150 CSV
151 </ButtonLink>
152 <ButtonLink variant="quiet" to={exportHref("json")} reloadDocument>
153 <Download size={14} />
154 JSON
155 </ButtonLink>
156 </div>
157 </Form>
158
159 <div className="mt-6">
160 {entries.length === 0 ? (
161 <EmptyState title={filtered ? "Nothing matches these filters" : "Nothing recorded yet"}>
162 {filtered
163 ? "Try a wider time range, or clear the filters."
164 : "Entries appear as agents work and as people change things through the API, MCP and git."}
165 </EmptyState>
166 ) : (
167 <AuditTable entries={entries} base={base} />
168 )}
169 </div>
170
171 {(next || filters.before) && (
172 <div className="mt-4 flex gap-4 text-sm">
173 {filters.before && (
174 <Link to={filterHref(base, { ...filters, before: "" })} className="text-muted hover:text-fg">
175 Newest
176 </Link>
177 )}
178 {next && (
179 <Link to={filterHref(base, filters, { before: next })} className="text-muted hover:text-fg">
180 Older
181 </Link>
182 )}
183 </div>
184 )}
185 </div>
186 );
187}