Skip to content

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

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