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