Skip to content
205 linesCodeBlameRaw
1/**
2 * The audit log's entries, as the workspace's Audit log page, an agent
3 * run's page and a pull request's Agent panel show them.
4 */
5
6import { ShieldAlert, ShieldCheck } from "lucide-react";
7import { useEffect } from "react";
8import { Link, useFetcher } from "react-router";
9
10import type { AuditEntry } from "@g1t/contracts";
11
12import { actionLabel, actorLabel, ruleLabel, targetLabel } from "../lib/audit";
13import { TimeAgo } from "./ui";
14import { Avatar } from "./ui/avatar";
15import { Card } from "./ui/card";
16import { Hint } from "./ui/hint";
17import { Loading, SkeletonRows } from "./ui/skeleton";
18
19function clock(at: string): string {
20 return new Date(at).toISOString().slice(11, 19);
21}
22
23/** Allowed or denied, and by which rule. */
24export function OutcomeMark({ entry }: { entry: AuditEntry }) {
25 const denied = entry.outcome === "denied";
26 return (
27 <Hint label={`${entry.outcome}: ${ruleLabel(entry.rule)} (${entry.rule})`}>
28 <span
29 className={`inline-flex shrink-0 items-center gap-1 rounded-full px-2 py-0.5 text-xs ${
30 denied ? "bg-danger/10 text-danger ring-1 ring-danger/30" : "bg-raised text-muted ring-1 ring-line"
31 }`}
32 >
33 {denied ? <ShieldAlert size={11} /> : <ShieldCheck size={11} />}
34 {entry.outcome}
35 </span>
36 </Hint>
37 );
38}
39
40/** Who acted, with an agent shown as working for someone. */
41export function ActorLine({ entry }: { entry: AuditEntry }) {
42 return (
43 <span className="flex min-w-0 items-center gap-2">
44 <Avatar name={entry.agent ?? entry.actor} size={18} square={entry.actorKind === "workspace"} />
45 <span className="truncate">
46 {entry.onBehalfOf ? (
47 <>
48 <span className="font-medium">{entry.agent ?? entry.actor}</span>
49 <span className="text-muted"> on behalf of </span>
50 <span className="font-medium">{entry.onBehalfOf}</span>
51 </>
52 ) : (
53 <span className="font-medium">{entry.actor}</span>
54 )}
55 </span>
56 </span>
57 );
58}
59
60/** The workspace's log, one row an entry, newest first. */
61export function AuditTable({ entries, base }: { entries: AuditEntry[]; base: string }) {
62 return (
63 <Card asChild divided>
64 <ol>
65 {entries.map((entry) => (
66 <li key={entry.id} className="grid gap-x-4 gap-y-1 px-4 py-3 text-sm sm:grid-cols-[9rem_1fr_auto]">
67 <span className="text-xs leading-5 text-faint">
68 <TimeAgo at={entry.time} />
69 </span>
70 <div className="min-w-0">
71 <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
72 <ActorLine entry={entry} />
73 <span className="font-mono text-xs text-fg/85">{actionLabel(entry.action)}</span>
74 <span className="truncate font-mono text-xs text-muted">{targetLabel(entry)}</span>
75 </div>
76 <p className="mt-1 flex flex-wrap gap-x-3 text-xs text-faint">
77 <Hint label={entry.rule}>
78 <span>{ruleLabel(entry.rule)}</span>
79 </Hint>
80 <span>{entry.surface.toUpperCase()}</span>
81 {entry.result && entry.result !== "ok" && <span>result: {entry.result}</span>}
82 {entry.runId && entry.repo && (
83 <Link to={`/${entry.repo}/agents/runs/${entry.runId}`} className="hover:text-fg">
84 {entry.runKind ?? "agent"} run
85 </Link>
86 )}
87 {entry.runId && (
88 <Link to={`${base}?run=${encodeURIComponent(entry.runId)}`} className="hover:text-fg">
89 everything this run did
90 </Link>
91 )}
92 {entry.credentialId && (
93 <Hint label="Credential">
94 <span className="font-mono">
95 <span className="sr-only">Credential </span>
96 {entry.credentialId}
97 </span>
98 </Hint>
99 )}
100 <Hint label="Request id">
101 <span className="font-mono">
102 <span className="sr-only">Request id </span>
103 {entry.requestId}
104 </span>
105 </Hint>
106 </p>
107 {entry.outcome === "denied" && entry.message && <p className="mt-1 text-xs text-danger">{entry.message}</p>}
108 </div>
109 <span className="sm:text-right">
110 <OutcomeMark entry={entry} />
111 </span>
112 </li>
113 ))}
114 </ol>
115 </Card>
116 );
117}
118
119/**
120 * What an agent's run did, oldest first: every call it made and every git
121 * request, allowed or refused. `entries` come from the run's audit log.
122 */
123export function WhatItDid({ entries, compact = false }: { entries: AuditEntry[]; compact?: boolean }) {
124 if (entries.length === 0) {
125 return (
126 <p className="mt-3 text-sm text-muted">
127 Nothing recorded yet. Every call this run makes with its credentials, and every clone and push, is listed here.
128 </p>
129 );
130 }
131 const who = entries.find((entry) => entry.onBehalfOf);
132 const denied = entries.filter((entry) => entry.outcome === "denied").length;
133 const shown = compact ? entries.slice(-8) : entries;
134 return (
135 <div className="mt-3">
136 <p className="text-xs text-muted">
137 {who ? actorLabel(who) : entries[0].actor} · {entries.length} {entries.length === 1 ? "action" : "actions"}
138 {denied > 0 && <span className="text-danger"> · {denied} refused</span>}
139 {compact && entries.length > shown.length && ` · the latest ${shown.length}`}
140 </p>
141 <Card asChild divided className="mt-2">
142 <ol>
143 {shown.map((entry) => (
144 <li key={entry.id} className="flex items-start gap-3 px-4 py-2 text-sm">
145 <time dateTime={entry.time} className="shrink-0 font-mono text-xs leading-5 text-faint" suppressHydrationWarning>
146 {clock(entry.time)}
147 </time>
148 <div className="min-w-0 grow">
149 <p className="flex min-w-0 flex-wrap items-center gap-x-2 font-mono text-xs leading-5">
150 <span className="text-fg/85">{actionLabel(entry.action)}</span>
151 <span className="truncate text-muted">{targetLabel(entry)}</span>
152 </p>
153 {entry.outcome === "denied" && (
154 <p className="text-xs text-danger">
155 {entry.message ?? "Refused."} <span className="text-faint">({ruleLabel(entry.rule)})</span>
156 </p>
157 )}
158 </div>
159 <OutcomeMark entry={entry} />
160 </li>
161 ))}
162 </ol>
163 </Card>
164 </div>
165 );
166}
167
168/**
169 * What the runs on a pull request did, fetched from the project's
170 * `audit.json`, for the Agent panel. Shown to members only.
171 */
172export function RunAudit({ owner, repo, runIds, live }: { owner: string; repo: string; runIds: string[]; live: boolean }) {
173 const fetcher = useFetcher<{ entries: AuditEntry[] }>();
174 const search = new URLSearchParams(runIds.map((id) => ["run", id])).toString();
175 const url = `/${owner}/${repo}/audit.json?${search}`;
176 const { load } = fetcher;
177 useEffect(() => {
178 if (runIds.length > 0) load(url);
179 }, [load, url, runIds.length]);
180 useEffect(() => {
181 if (!live) return;
182 const timer = setInterval(() => {
183 if (document.visibilityState === "visible") load(url);
184 }, 8000);
185 return () => clearInterval(timer);
186 }, [live, load, url]);
187 const entries = fetcher.data?.entries;
188 if (runIds.length === 0) return null;
189 // There from the start, so the panel does not grow when the log arrives.
190 return (
191 <details className="mt-3 group">
192 <summary className="cursor-pointer text-xs text-muted hover:text-fg">What it did</summary>
193 {entries ? (
194 <WhatItDid entries={entries} compact />
195 ) : (
196 <Loading className="mt-3 overflow-hidden rounded-xl border border-line bg-surface">
197 <SkeletonRows rows={3} rowClassName="h-9 px-4" />
198 </Loading>
199 )}
200 <Link to={`/${owner}/-/audit?project=${encodeURIComponent(repo)}&kind=agent`} className="mt-2 inline-block text-xs text-muted hover:text-fg">
201 Open the audit log
202 </Link>
203 </details>
204 );
205}