g1t/apps/web/app/components/security-suite.tsx
| 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 | */ |
| 8 | import { Bot, CircleCheck, CircleDot, CircleSlash, FileCode2, Lock, ShieldCheck, Sparkles } from "lucide-react"; |
| 9 | import { type ReactNode, useEffect, useState } from "react"; |
| 10 | import { Link, useFetcher } from "react-router"; |
| 11 | |
| 12 | import { |
| 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 | |
| 24 | import { total, trendMax } from "../lib/security-suite"; |
| 25 | import { SeverityBadge } from "./security"; |
| 26 | import { TimeAgo } from "./ui"; |
| 27 | import { Badge } from "./ui/badge"; |
| 28 | import { RadioGroup, RadioOption } from "./ui/radio-group"; |
| 29 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; |
| 30 | |
| 31 | type Done = { ok: boolean; error?: string } | undefined; |
| 32 | |
| 33 | export const CARD = "rounded-xl border border-line bg-surface"; |
| 34 | export const LIST = "divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"; |
| 35 | const 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"; |
| 37 | const 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"; |
| 39 | const 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. */ |
| 42 | export 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 | */ |
| 59 | export 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. */ |
| 102 | export 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 | |
| 132 | const 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. */ |
| 139 | export 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. */ |
| 181 | export 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". */ |
| 187 | export 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). |
| 194 | const 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. */ |
| 201 | export 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 | |
| 289 | function 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. */ |
| 301 | export 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. */ |
| 381 | export 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. */ |
| 409 | export 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 | |
| 448 | export 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. */ |
| 459 | function 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 | */ |
| 486 | export 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 | |
| 594 | function 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. */ |
| 627 | export 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 | } |