Skip to content

g1t/apps/web/app/components/security-suite.tsx

641 lines27,447 bytesCodeBlame
1/**
2 * The security suite's pieces, shared by a project's Security sections and
3 * the workspace's: the activation prompt, filters, code scanning alerts,
4 * the trend chart and coverage table, the custom pattern editor, and the
5 * bypass and "Fix with g1t" forms. Each form posts an `intent` to the page
6 * it is on.
7 */
8import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck, Sparkles } from "lucide-react";
9import { type ReactNode, useEffect, useState } from "react";
10import { Link, useFetcher } from "react-router";
11
12import {
13 type AlertState,
14 BYPASS_REASONS,
15 type CodeAlert,
16 type DryRun,
17 type RepoCoverage,
18 type SavedPattern,
19 type Severity,
20 type SeverityCounts,
21 type TrendPoint,
22} from "@g1t/contracts";
23
24import { total, trendMax } from "../lib/security-suite";
25import { SeverityBadge } from "./security";
26import { TimeAgo } from "./ui";
27import { Badge } from "./ui/badge";
28import { RadioGroup, RadioOption } from "./ui/radio-group";
29import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
30
31type Done = { ok: boolean; error?: string } | undefined;
32
33export const CARD = "rounded-xl border border-line bg-surface";
34export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface";
35const INPUT =
36 "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
37const SMALL_BUTTON =
38 "rounded-md border border-line px-2.5 py-1 text-xs font-medium text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-50";
39const PRIMARY = "rounded-md bg-fg px-3.5 py-2 text-sm font-medium text-bg transition-colors hover:bg-white disabled:opacity-50";
40
41/** A section's title, what it is for, and what can be done there. */
42export function SectionHeader({ title, about, actions }: { title: string; about: ReactNode; actions?: ReactNode }) {
43 return (
44 <div className="flex flex-wrap items-start justify-between gap-4">
45 <div className="min-w-0 max-w-2xl">
46 <h2 className="text-xl font-semibold tracking-tight">{title}</h2>
47 <p className="mt-1.5 text-sm text-muted">{about}</p>
48 </div>
49 {actions && <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div>}
50 </div>
51 );
52}
53
54/**
55 * What a private repository's paid feature needs: the Security and quality
56 * activation, its price from the price book, and who can turn it on. No
57 * pressure: what stays free is said too.
58 */
59export function ActivationPrompt({
60 workspace,
61 feature,
62 monthlyCents,
63 isOwner,
64}: {
65 workspace: string;
66 feature: string;
67 /** From billing's price book; null when it could not be read. */
68 monthlyCents: number | null;
69 isOwner: boolean;
70}) {
71 const price = monthlyCents == null ? null : `$${(monthlyCents / 100).toFixed(monthlyCents % 100 ? 2 : 0)} a month`;
72 return (
73 <div className={`${CARD} flex flex-col gap-4 p-5 sm:flex-row sm:items-start`}>
74 <Lock size={18} className="mt-0.5 shrink-0 text-accent" />
75 <div className="min-w-0 grow">
76 <p className="font-medium">{feature} on private repositories comes with Security and quality</p>
77 <p className="mt-1.5 text-sm text-muted">
78 The activation turns on custom patterns, validity checks, delegated bypass, code scanning, dependency review and the
79 security overview for every private repository in {workspace}
80 {price ? `, for ${price}, whoever is in the workspace` : ""}. Fixes by g1t's agent are charged as agent usage. Public
81 repositories have all of it free, and secret scanning, push protection, vulnerability alerts and security updates stay
82 free everywhere.
83 </p>
84 <div className="mt-3 flex flex-wrap items-center gap-3 text-sm">
85 {isOwner ? (
86 <Link to={`/${workspace}/-/billing#security`} className={PRIMARY}>
87 Turn it on
88 </Link>
89 ) : (
90 <span className="text-muted">An owner of {workspace} can turn it on in Billing.</span>
91 )}
92 <a href="https://docs.g1t.sh/guides/security/pricing/" className="text-muted underline underline-offset-2 hover:text-fg">
93 What's free and what's paid
94 </a>
95 </div>
96 </div>
97 </div>
98 );
99}
100
101/** A labelled select that changes a filter in the address. */
102export function FilterSelect({
103 label,
104 value,
105 options,
106 onChange,
107}: {
108 label: string;
109 value: string;
110 options: [string, string][];
111 onChange: (value: string) => void;
112}) {
113 return (
114 <label className="flex min-w-0 flex-col gap-1 text-xs text-muted">
115 {label}
116 <Select value={value} onValueChange={onChange}>
117 <SelectTrigger size="sm" className="min-w-36" aria-label={label}>
118 <SelectValue />
119 </SelectTrigger>
120 <SelectContent>
121 {options.map(([option, text]) => (
122 <SelectItem key={option} value={option}>
123 {text}
124 </SelectItem>
125 ))}
126 </SelectContent>
127 </Select>
128 </label>
129 );
130}
131
132const STATE_ICON: Record<AlertState, ReactNode> = {
133 open: <CircleDot size={14} />,
134 dismissed: <CircleSlash size={14} />,
135 fixed: <CircleCheck size={14} />,
136};
137
138/** A code scanning alert in a list. */
139export function CodeAlertItem({ alert, base }: { alert: CodeAlert; base: string }) {
140 const where = alert.path ? `${alert.path}${alert.startLine ? `:${alert.startLine}` : ""}` : null;
141 return (
142 <li className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-start">
143 <span className={`mt-0.5 hidden shrink-0 sm:block ${alert.state === "open" ? "text-warn" : "text-muted"}`}>{STATE_ICON[alert.state]}</span>
144 <div className="min-w-0 grow">
145 <div className="flex flex-wrap items-center gap-2">
146 <Link to={`${base}/security/code-scanning/${alert.number}`} className="min-w-0 text-sm font-medium break-words hover:underline">
147 {alert.ruleName && alert.ruleName !== alert.ruleId ? alert.ruleName : shortRule(alert.ruleId)}
148 </Link>
149 <SeverityBadge severity={alert.severity} />
150 {alert.issue != null && (
151 <Link to={`${base}/issues/${alert.issue}`}>
152 <Badge tone="accent">
153 <Bot size={11} /> g1t on #{alert.issue}
154 </Badge>
155 </Link>
156 )}
157 </div>
158 <p className="mt-1 line-clamp-2 text-xs text-muted">{alert.message}</p>
159 <p className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1 text-xs text-faint">
160 <span>#{alert.number}</span>
161 <span>{alert.tool}</span>
162 {where && <span className="truncate font-mono">{where}</span>}
163 <span>
164 {alert.state === "fixed" && alert.fixedAt ? (
165 <>
166 fixed <TimeAgo at={alert.fixedAt} />
167 </>
168 ) : (
169 <>
170 found <TimeAgo at={alert.createdAt} />
171 </>
172 )}
173 </span>
174 </p>
175 </div>
176 </li>
177 );
178}
179
180/** The last part of a dotted rule id: Semgrep's are long. */
181export function shortRule(ruleId: string): string {
182 const parts = ruleId.split(".");
183 return parts.length > 2 ? parts[parts.length - 1] : ruleId;
184}
185
186/** Open counts as compact words: "3 critical · 1 high". */
187export function countsLine(counts: SeverityCounts): string {
188 const parts = (["critical", "high", "medium", "low"] as Severity[]).filter((s) => counts[s] > 0).map((s) => `${counts[s]} ${s}`);
189 return parts.length ? parts.join(" · ") : "none open";
190}
191
192// The trend's three series, each its own hue, validated for the dark
193// surface (dataviz: categorical slots 1–3, dark steps).
194const SERIES = [
195 { key: "secretScanning", label: "Secrets", color: "#3987e5" },
196 { key: "codeScanning", label: "Code scanning", color: "#d95926" },
197 { key: "vulnerability", label: "Vulnerabilities", color: "#199e70" },
198] as const;
199
200/** Open alerts by type each day, as stacked bars with a legend and a table. */
201export function TrendChart({ points }: { points: TrendPoint[] }) {
202 const [hover, setHover] = useState<number | null>(null);
203 const max = trendMax(points);
204 const width = 640;
205 const height = 140;
206 const gap = 2;
207 const bar = Math.max(2, width / Math.max(points.length, 1) - gap);
208 const shown = hover != null ? points[hover] : points[points.length - 1];
209 return (
210 <figure className={`${CARD} p-4`}>
211 <figcaption className="flex flex-wrap items-baseline justify-between gap-2">
212 <span className="text-sm font-medium">Open alerts, by day</span>
213 <span className="text-xs text-muted" aria-live="polite">
214 {shown
215 ? `${shown.day}: ${SERIES.map((s) => `${shown[s.key]} ${s.label.toLowerCase()}`).join(", ")}`
216 : "No days yet"}
217 </span>
218 </figcaption>
219 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted" aria-label="Legend">
220 {SERIES.map((series) => (
221 <li key={series.key} className="flex items-center gap-1.5">
222 <span aria-hidden="true" className="size-2.5 rounded-sm" style={{ background: series.color }} />
223 {series.label}
224 </li>
225 ))}
226 </ul>
227 <svg
228 viewBox={`0 0 ${width} ${height}`}
229 preserveAspectRatio="none"
230 className="mt-3 h-36 w-full"
231 role="img"
232 aria-label="Open alerts by type each day; the table below has the numbers"
233 onMouseLeave={() => setHover(null)}
234 >
235 <line x1={0} x2={width} y1={height - 0.5} y2={height - 0.5} stroke="currentColor" className="text-line" />
236 {points.map((point, at) => {
237 const x = at * (bar + gap);
238 let y = height;
239 return (
240 <g key={point.day} onMouseEnter={() => setHover(at)}>
241 {/* A hit target the full height of the day. */}
242 <rect x={x} y={0} width={bar + gap} height={height} fill="transparent" />
243 {SERIES.map((series) => {
244 const value = point[series.key];
245 if (!value) return null;
246 const h = Math.max(1, (value / max) * (height - 8));
247 y -= h;
248 const rect = <rect key={series.key} x={x} y={y} width={bar} height={Math.max(0, h - gap)} rx={Math.min(2, bar / 2)} fill={series.color} opacity={hover == null || hover === at ? 1 : 0.55} />;
249 return rect;
250 })}
251 <title>{`${point.day}: ${SERIES.map((s) => `${point[s.key]} ${s.label.toLowerCase()}`).join(", ")}`}</title>
252 </g>
253 );
254 })}
255 </svg>
256 <details className="mt-2">
257 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Show as a table</summary>
258 <div className="mt-2 max-h-56 overflow-auto">
259 <table className="w-full text-xs tabular-nums">
260 <thead className="text-left text-muted">
261 <tr>
262 <th className="py-1 font-medium">Day</th>
263 {SERIES.map((series) => (
264 <th key={series.key} className="py-1 text-right font-medium">
265 {series.label}
266 </th>
267 ))}
268 </tr>
269 </thead>
270 <tbody>
271 {[...points].reverse().map((point) => (
272 <tr key={point.day} className="border-t border-line">
273 <td className="py-1">{point.day}</td>
274 {SERIES.map((series) => (
275 <td key={series.key} className="py-1 text-right">
276 {point[series.key]}
277 </td>
278 ))}
279 </tr>
280 ))}
281 </tbody>
282 </table>
283 </div>
284 </details>
285 </figure>
286 );
287}
288
289function On({ on, children }: { on: boolean; children?: ReactNode }) {
290 return on ? (
291 <span className="inline-flex items-center gap-1 text-accent">
292 <CircleCheck size={13} aria-hidden="true" />
293 {children ?? "On"}
294 </span>
295 ) : (
296 <span className="text-faint">{children ? children : "Off"}</span>
297 );
298}
299
300/** Which repository has which feature on, and what is open in each. */
301export function CoverageTable({ repos, owner }: { repos: RepoCoverage[]; owner: string }) {
302 if (repos.length === 0) {
303 return (
304 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">
305 No repository has been scanned yet. Each one is scanned on its next push to its default branch, or when its Security page
306 is first opened.
307 </p>
308 );
309 }
310 return (
311 <div className={`${CARD} overflow-x-auto`}>
312 <table className="w-full min-w-[46rem] text-sm">
313 <thead className="text-left text-xs text-muted">
314 <tr className="border-b border-line">
315 <th className="px-4 py-2.5 font-medium">Repository</th>
316 <th className="px-3 py-2.5 font-medium">Open</th>
317 <th className="px-3 py-2.5 font-medium">Push protection</th>
318 <th className="px-3 py-2.5 font-medium">Custom patterns</th>
319 <th className="px-3 py-2.5 font-medium">Code scanning</th>
320 <th className="px-3 py-2.5 font-medium">Dependency review</th>
321 <th className="px-3 py-2.5 font-medium">Security updates</th>
322 </tr>
323 </thead>
324 <tbody>
325 {repos.map((repo) => {
326 const open = total(repo.secrets) + total(repo.code) + total(repo.vulnerabilities);
327 const critical = repo.secrets.critical + repo.code.critical + repo.vulnerabilities.critical;
328 const high = repo.secrets.high + repo.code.high + repo.vulnerabilities.high;
329 return (
330 <tr key={repo.repoId} className="border-b border-line last:border-0">
331 <td className="px-4 py-2.5">
332 <Link to={`/${owner}/${repo.name}/security`} className="font-mono text-sm font-medium hover:underline">
333 {repo.name}
334 </Link>
335 {repo.private && <span className="ml-2 text-xs text-faint">private</span>}
336 </td>
337 <td className="px-3 py-2.5 text-xs whitespace-nowrap">
338 {open === 0 ? (
339 <span className="inline-flex items-center gap-1 text-accent">
340 <ShieldCheck size={13} /> None
341 </span>
342 ) : (
343 <span className="flex flex-wrap gap-1">
344 {critical > 0 && <Badge tone="danger">{critical} critical</Badge>}
345 {high > 0 && <Badge tone="warn">{high} high</Badge>}
346 {open - critical - high > 0 && <Badge>{open - critical - high} other</Badge>}
347 </span>
348 )}
349 </td>
350 <td className="px-3 py-2.5 text-xs">
351 <On on>On</On>
352 </td>
353 <td className="px-3 py-2.5 text-xs tabular-nums">
354 <On on={repo.customPatterns > 0}>{repo.customPatterns > 0 ? `${repo.customPatterns}` : "None"}</On>
355 </td>
356 <td className="px-3 py-2.5 text-xs">
357 {repo.codeScanningAt ? (
358 <span className="text-accent">
359 <TimeAgo at={repo.codeScanningAt} />
360 </span>
361 ) : (
362 <span className="text-faint">Not set up</span>
363 )}
364 </td>
365 <td className="px-3 py-2.5 text-xs">
366 <On on={repo.dependencyReview && repo.lockfiles > 0}>{repo.lockfiles === 0 ? "No lockfiles" : undefined}</On>
367 </td>
368 <td className="px-3 py-2.5 text-xs">
369 <On on={repo.securityUpdates} />
370 </td>
371 </tr>
372 );
373 })}
374 </tbody>
375 </table>
376 </div>
377 );
378}
379
380/** "Fix with g1t": an issue for g1t, as the person asking. */
381export function FixWithG1t({ id, action, issue, base }: { id: string; action: string; issue: number | null; base: string }) {
382 const fetcher = useFetcher<{ ok: boolean; error?: string; issue?: number; message?: string | null }>();
383 const opened = fetcher.data?.issue ?? issue;
384 if (opened != null) {
385 return (
386 <Link to={`${base}/issues/${opened}`} className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}>
387 <Bot size={13} /> g1t is on #{opened}
388 </Link>
389 );
390 }
391 return (
392 <span className="flex flex-col items-end gap-1">
393 <button
394 type="button"
395 disabled={fetcher.state !== "idle"}
396 onClick={() => fetcher.submit({ intent: "fix", id }, { method: "post", action })}
397 className={`${SMALL_BUTTON} inline-flex items-center gap-1.5`}
398 title="Opens an issue assigned to g1t; its run is charged as agent usage"
399 >
400 <Sparkles size={13} />
401 {fetcher.state !== "idle" ? "Opening…" : "Fix with g1t"}
402 </button>
403 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
404 </span>
405 );
406}
407
408/** Pushing past push protection with a reason, or asking to. */
409export function BypassForm({ id, action, request }: { id: string; action: string; request: boolean }) {
410 const fetcher = useFetcher<Done & { requested?: boolean }>();
411 const [reason, setReason] = useState("");
412 if (fetcher.data?.ok) {
413 return (
414 <p className="rounded-md border border-accent/30 bg-accent/5 px-3 py-2 text-sm text-accent">
415 {fetcher.data.requested
416 ? "Asked. The workspace's owners and the repository's admins were told; push again once one approves."
417 : "Bypassed. Push again and it goes through."}
418 </p>
419 );
420 }
421 return (
422 <fetcher.Form method="post" action={action} className="space-y-3">
423 <input type="hidden" name="intent" value="bypass" />
424 <input type="hidden" name="id" value={id} />
425 <fieldset>
426 <legend className="text-xs font-medium text-muted">Why does it need to go through?</legend>
427 <RadioGroup name="reason" value={reason} onValueChange={setReason} required className="mt-2 gap-3">
428 {BYPASS_REASONS.map((option) => (
429 <RadioOption key={option.reason} value={option.reason} label={option.label} description={option.about} />
430 ))}
431 </RadioGroup>
432 </fieldset>
433 <label className="block">
434 <span className="mb-1.5 block text-xs font-medium text-muted">Comment (optional)</span>
435 <textarea name="comment" rows={2} maxLength={500} className={INPUT} placeholder="What a reviewer should know." />
436 </label>
437 <div className="flex flex-wrap items-center gap-3">
438 <button type="submit" disabled={fetcher.state !== "idle" || !reason} className={PRIMARY}>
439 {fetcher.state !== "idle" ? "Sending…" : request ? "Ask to bypass" : "Bypass push protection"}
440 </button>
441 <span className="text-xs text-muted">Recorded with your name and reason, on the alert and in the audit log.</span>
442 </div>
443 {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
444 </fetcher.Form>
445 );
446}
447
448export type PatternDraft = {
449 id?: string;
450 name: string;
451 pattern: string;
452 before: string;
453 after: string;
454 testStrings: string;
455 published: boolean;
456};
457
458/** Where a test string matched, the match marked. */
459function TestResult({ text, found }: { text: string; found: [number, number] | null }) {
460 if (!found) {
461 return (
462 <li className="flex items-start gap-2 font-mono text-xs break-all">
463 <CircleSlash size={13} className="mt-0.5 shrink-0 text-faint" />
464 <span className="text-muted">{text}</span>
465 </li>
466 );
467 }
468 const chars = [...text];
469 return (
470 <li className="flex items-start gap-2 font-mono text-xs break-all">
471 <CircleCheck size={13} className="mt-0.5 shrink-0 text-accent" />
472 <span>
473 {chars.slice(0, found[0]).join("")}
474 <mark className="rounded-sm bg-accent/25 text-fg">{chars.slice(found[0], found[1]).join("")}</mark>
475 {chars.slice(found[1]).join("")}
476 </span>
477 </li>
478 );
479}
480
481/**
482 * Making or changing a custom pattern: its regular expression, what comes
483 * before and after it, test strings, a dry run over the default branch,
484 * and saving it as a draft or published.
485 */
486export function PatternEditor({ draft, action, onDone }: { draft: PatternDraft; action: string; onDone?: () => void }) {
487 const save = useFetcher<{ ok: boolean; error?: string; saved?: SavedPattern }>();
488 const dry = useFetcher<{ ok: boolean; error?: string; dryRun?: DryRun }>();
489 const [form, setForm] = useState(draft);
490 useEffect(() => {
491 if (save.state === "idle" && save.data?.ok && onDone) onDone();
492 }, [save.state, save.data, onDone]);
493 const set = (key: keyof PatternDraft) => (event: { target: { value: string } }) => setForm({ ...form, [key]: event.target.value });
494 const fields = (publish: boolean) => ({
495 intent: "save_pattern",
496 id: form.id ?? "",
497 name: form.name,
498 pattern: form.pattern,
499 before: form.before,
500 after: form.after,
501 testStrings: form.testStrings,
502 publish: String(publish),
503 });
504 const tests = form.testStrings.split("\n").filter((line) => line.trim());
505 const saved = save.data?.saved;
506 return (
507 <div className="space-y-4">
508 <div className="grid gap-4 sm:grid-cols-2">
509 <label className="block">
510 <span className="mb-1.5 block text-xs font-medium text-muted">Name</span>
511 <input value={form.name} onChange={set("name")} maxLength={100} placeholder="Acme API key" className={INPUT} />
512 </label>
513 <label className="block sm:col-span-2">
514 <span className="mb-1.5 block text-xs font-medium text-muted">Secret format (regular expression)</span>
515 <input
516 value={form.pattern}
517 onChange={set("pattern")}
518 maxLength={1000}
519 spellCheck={false}
520 placeholder="acme_[a-z0-9]{32}"
521 className={`${INPUT} font-mono`}
522 />
523 </label>
524 <label className="block">
525 <span className="mb-1.5 block text-xs font-medium text-muted">Before the secret (optional)</span>
526 <input value={form.before} onChange={set("before")} spellCheck={false} placeholder="\A|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
527 </label>
528 <label className="block">
529 <span className="mb-1.5 block text-xs font-medium text-muted">After the secret (optional)</span>
530 <input value={form.after} onChange={set("after")} spellCheck={false} placeholder="\z|[^0-9A-Za-z]" className={`${INPUT} font-mono`} />
531 </label>
532 <label className="block sm:col-span-2">
533 <span className="mb-1.5 block text-xs font-medium text-muted">Test strings, one a line</span>
534 <textarea value={form.testStrings} onChange={set("testStrings")} rows={3} spellCheck={false} className={`${INPUT} font-mono`} />
535 </label>
536 </div>
537 <p className="text-xs text-muted">
538 Patterns use the Rust regex syntax, which runs in time linear in the text: no look-around or back-references. Before and
539 after default to a line edge or a character that is not a letter or digit.
540 </p>
541 {saved && tests.length > 0 && (
542 <ul className="space-y-1">
543 {tests.map((text, at) => (
544 <TestResult key={at} text={text} found={saved.tests[at] ?? null} />
545 ))}
546 </ul>
547 )}
548 <div className="flex flex-wrap items-center gap-2">
549 <button
550 type="button"
551 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
552 onClick={() => save.submit(fields(true), { method: "post", action })}
553 className={PRIMARY}
554 >
555 {save.state !== "idle" ? "Saving…" : form.published ? "Save" : "Publish"}
556 </button>
557 <button
558 type="button"
559 disabled={save.state !== "idle" || !form.name.trim() || !form.pattern.trim()}
560 onClick={() => save.submit(fields(false), { method: "post", action })}
561 className={SMALL_BUTTON}
562 >
563 {form.published ? "Unpublish, keep as draft" : "Save as draft"}
564 </button>
565 <button
566 type="button"
567 disabled={dry.state !== "idle" || !form.pattern.trim()}
568 onClick={() => dry.submit({ intent: "dry_run", pattern: form.pattern, before: form.before, after: form.after }, { method: "post", action })}
569 className={SMALL_BUTTON}
570 >
571 {dry.state !== "idle" ? "Running…" : "Dry run"}
572 </button>
573 {form.id && (
574 <button
575 type="button"
576 disabled={save.state !== "idle"}
577 onClick={() => {
578 if (confirm(`Delete "${form.name}"? The alerts it found stay.`)) save.submit({ intent: "delete_pattern", id: form.id ?? "" }, { method: "post", action });
579 }}
580 className="ml-auto rounded-md border border-danger/40 px-2.5 py-1 text-xs font-medium text-danger hover:bg-danger/10 disabled:opacity-50"
581 >
582 Delete
583 </button>
584 )}
585 </div>
586 {save.data?.error && <p className="text-sm text-danger">{save.data.error}</p>}
587 {save.data?.ok && (saved ? <p className="text-sm text-accent">Saved{saved.pattern.state === "published" ? " and published: the history is scanned again for it" : " as a draft"}.</p> : <p className="text-sm text-accent">Deleted. The alerts it found stay.</p>)}
588 {dry.data?.error && <p className="text-sm text-danger">{dry.data.error}</p>}
589 {dry.data?.dryRun && <DryRunResults dryRun={dry.data.dryRun} />}
590 </div>
591 );
592}
593
594function DryRunResults({ dryRun }: { dryRun: DryRun }) {
595 return (
596 <div className="space-y-3">
597 {dryRun.repos.map((repo) => (
598 <section key={repo.name} className={`${CARD} p-3`}>
599 <p className="text-sm">
600 <span className="font-mono font-medium">{repo.name}</span>
601 <span className="text-muted">
602 {" "}
603 · {repo.matches.length} {repo.matches.length === 1 ? "match" : "matches"} in {repo.filesScanned} files
604 {repo.truncated ? " (stopped early)" : ""}
605 </span>
606 </p>
607 {repo.matches.length > 0 && (
608 <ul className="mt-2 space-y-1">
609 {repo.matches.map((match, at) => (
610 <li key={at} className="font-mono text-xs break-all">
611 <span className="inline-flex items-center gap-1 text-fg-soft">
612 <FileCode2 size={12} />
613 {match.path}:{match.line}
614 </span>
615 <span className="text-faint"> {match.preview}</span>
616 </li>
617 ))}
618 </ul>
619 )}
620 </section>
621 ))}
622 </div>
623 );
624}
625
626/** Reads a pattern form's fields, as the pages' actions take them. */
627export function patternFields(form: FormData) {
628 const text = (key: string) => String(form.get(key) ?? "");
629 return {
630 id: text("id") || undefined,
631 name: text("name").trim(),
632 pattern: text("pattern"),
633 before: text("before").trim() || null,
634 after: text("after").trim() || null,
635 testStrings: text("testStrings")
636 .split("\n")
637 .map((line) => line.replace(/\r$/, ""))
638 .filter((line) => line.trim()),
639 publish: text("publish") === "true",
640 };
641}