| 1 | import { Download } from "lucide-react"; |
| 2 | import { Link, useSearchParams } from "react-router"; |
| 3 | |
| 4 | import type { Route } from "./+types/security-graph"; |
| 5 | import { page } from "../../lib/meta"; |
| 6 | import { CARD, FilterSelect, SectionHeader } from "../../components/security-suite"; |
| 7 | import { Badge } from "../../components/ui/badge"; |
| 8 | import { securitySuite } from "../../lib/services.server"; |
| 9 | import { getViewer, requireUser, unwrap } from "../../lib/session.server"; |
| 10 | import { requireInsider } from "../../lib/access.server"; |
| 11 | |
| 12 | export function meta({ params, ...args }: Route.MetaArgs) { |
| 13 | return page(args, { title: `Dependency graph · ${params.owner}/${params.repo} · g1t` }); |
| 14 | } |
| 15 | |
| 16 | export async function loader({ params, context, request }: Route.LoaderArgs) { |
| 17 | const viewer = getViewer(context) ?? requireUser(context, request); |
| 18 | await requireInsider(context, params, "push"); |
| 19 | return { graph: unwrap(await securitySuite.dependencyGraph({ namespace: params.owner, name: params.repo }, viewer)) }; |
| 20 | } |
| 21 | |
| 22 | /** Rows shown at once; the SBOM has them all. */ |
| 23 | const SHOWN = 500; |
| 24 | |
| 25 | export default function DependencyGraph({ loaderData, params }: Route.ComponentProps) { |
| 26 | const { graph } = loaderData; |
| 27 | const base = `/${params.owner}/${params.repo}`; |
| 28 | const [search, setSearch] = useSearchParams(); |
| 29 | const manifest = search.get("manifest") ?? "all"; |
| 30 | const relationship = search.get("relationship") ?? "all"; |
| 31 | const query = (search.get("q") ?? "").toLowerCase(); |
| 32 | const set = (key: string, value: string) => { |
| 33 | const next = new URLSearchParams(search); |
| 34 | if (value === "all" || !value) next.delete(key); |
| 35 | else next.set(key, value); |
| 36 | setSearch(next, { replace: true, preventScrollReset: true }); |
| 37 | }; |
| 38 | const shown = graph.dependencies.filter( |
| 39 | (dep) => |
| 40 | (manifest === "all" || dep.manifest === manifest) && |
| 41 | (relationship === "all" || dep.relationship === relationship) && |
| 42 | (!query || dep.name.toLowerCase().includes(query)), |
| 43 | ); |
| 44 | return ( |
| 45 | <div className="max-w-5xl space-y-6"> |
| 46 | <SectionHeader |
| 47 | title="Dependency graph" |
| 48 | about="Every package the lockfiles on the default branch resolve: direct or transitive where the lockfile says, for development or not, and its license where the lockfile records one. Read again on every push to the default branch, and daily." |
| 49 | actions={ |
| 50 | graph.dependencies.length > 0 ? ( |
| 51 | <a href={`${base}/security/dependency-graph/sbom.json`} download className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong"> |
| 52 | <Download size={14} /> Export SBOM (SPDX) |
| 53 | </a> |
| 54 | ) : null |
| 55 | } |
| 56 | /> |
| 57 | {graph.manifests.length === 0 ? ( |
| 58 | <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted"> |
| 59 | No lockfiles on the default branch. g1t reads package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, go.mod, go.sum, |
| 60 | requirements.txt and poetry.lock. |
| 61 | </p> |
| 62 | ) : ( |
| 63 | <> |
| 64 | <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> |
| 65 | {graph.manifests.map((item) => ( |
| 66 | <li key={item.path} className={`${CARD} p-3`}> |
| 67 | <p className="truncate font-mono text-sm">{item.path}</p> |
| 68 | <p className="mt-1 text-xs text-muted"> |
| 69 | {item.ecosystem} · {item.dependencies} packages{item.direct ? `, ${item.direct} direct` : ""} |
| 70 | </p> |
| 71 | </li> |
| 72 | ))} |
| 73 | </ul> |
| 74 | <div className="flex flex-wrap items-end gap-2"> |
| 75 | <label className="flex min-w-0 grow flex-col gap-1 text-xs text-muted sm:max-w-xs"> |
| 76 | Package |
| 77 | <input |
| 78 | defaultValue={search.get("q") ?? ""} |
| 79 | onChange={(event) => set("q", event.target.value)} |
| 80 | placeholder="Filter by name" |
| 81 | className="h-8 rounded-md border border-line bg-bg px-2.5 text-[0.8125rem] text-fg outline-none hover:border-line-strong focus:border-accent-dim" |
| 82 | /> |
| 83 | </label> |
| 84 | <FilterSelect label="Lockfile" value={manifest} options={[["all", "Every lockfile"], ...graph.manifests.map((item): [string, string] => [item.path, item.path])]} onChange={(value) => set("manifest", value)} /> |
| 85 | <FilterSelect |
| 86 | label="Relationship" |
| 87 | value={relationship} |
| 88 | options={[["all", "Any"], ["direct", "Direct"], ["transitive", "Transitive"], ["unknown", "Not said"]]} |
| 89 | onChange={(value) => set("relationship", value)} |
| 90 | /> |
| 91 | </div> |
| 92 | <div className={`${CARD} overflow-x-auto`}> |
| 93 | <table className="w-full min-w-[40rem] text-sm"> |
| 94 | <thead className="text-left text-xs text-muted"> |
| 95 | <tr className="border-b border-line"> |
| 96 | <th className="px-4 py-2 font-medium">Package</th> |
| 97 | <th className="px-3 py-2 font-medium">Version</th> |
| 98 | <th className="px-3 py-2 font-medium">Relationship</th> |
| 99 | <th className="px-3 py-2 font-medium">License</th> |
| 100 | <th className="px-3 py-2 font-medium">Lockfile</th> |
| 101 | </tr> |
| 102 | </thead> |
| 103 | <tbody> |
| 104 | {shown.slice(0, SHOWN).map((dep) => ( |
| 105 | <tr key={`${dep.manifest}:${dep.name}@${dep.version}`} className="border-b border-line last:border-0"> |
| 106 | <td className="px-4 py-2"> |
| 107 | <span className="font-mono text-xs">{dep.name}</span> |
| 108 | {dep.vulnerabilities > 0 && ( |
| 109 | <Link to={`${base}/security/vulnerabilities`} className="ml-2"> |
| 110 | <Badge tone="danger"> |
| 111 | {dep.vulnerabilities} {dep.vulnerabilities === 1 ? "vulnerability" : "vulnerabilities"} |
| 112 | </Badge> |
| 113 | </Link> |
| 114 | )} |
| 115 | </td> |
| 116 | <td className="px-3 py-2 font-mono text-xs">{dep.version}</td> |
| 117 | <td className="px-3 py-2 text-xs text-muted"> |
| 118 | {dep.relationship === "unknown" ? "not said" : dep.relationship} |
| 119 | {dep.development ? ", dev" : ""} |
| 120 | </td> |
| 121 | <td className="px-3 py-2 text-xs text-muted">{dep.license ?? "—"}</td> |
| 122 | <td className="px-3 py-2 font-mono text-xs text-faint">{dep.manifest}</td> |
| 123 | </tr> |
| 124 | ))} |
| 125 | </tbody> |
| 126 | </table> |
| 127 | </div> |
| 128 | <p className="text-xs text-faint"> |
| 129 | {shown.length > SHOWN ? `The first ${SHOWN} of ${shown.length} shown; the SBOM has every package.` : `${shown.length} packages.`} |
| 130 | {graph.commit && <> Read at {graph.commit.slice(0, 7)}.</>} |
| 131 | </p> |
| 132 | </> |
| 133 | )} |
| 134 | </div> |
| 135 | ); |
| 136 | } |