From 15b3a06ec98633fa2b894b9337b49d83210ce70d Mon Sep 17 00:00:00 2001 From: Haksung Jang Date: Fri, 14 Aug 2026 17:43:44 +0900 Subject: [PATCH] feat(web): link the component and vulnerability lists to each other Investigating a finding meant retyping a name into the other section's search box: a CVE names its package, a component row shows a severity, and neither reached the other. A component's expanded detail now opens the vulnerability list filtered to that component, and a vulnerability's detail opens the component list filtered to its package. Both reuse the seeding the Overview jump cards already use. The links sit in the expanded detail rather than on the row's cells because each row is itself the expand control, and a control nested in a control is not announced reliably. axe rejected the first placement on that rule. The same axe run reported a real contrast defect next to it: the fixed-version column measured 3.77:1 against the light background, so it moves one shade darker. The visual baselines are unchanged. The two shades sit within Playwright's per-pixel colour threshold, so the comparison counts no differing pixels and a reseed regenerates nothing. Modal and section animations are now settled before an accessibility check runs, through a shared test helper. A check taken mid-fade reads blended colours and reported a 4.6:1 pair as 4.23:1. --- CHANGELOG.md | 4 + .../src/components/ComponentsTable.tsx | 29 ++- .../web/frontend/src/components/NextApp.tsx | 10 +- .../src/components/ResultSections.tsx | 20 +- .../src/components/VulnerabilitiesTable.tsx | 45 ++++- .../web/frontend/src/locales/en/common.json | 4 +- .../web/frontend/src/locales/ko/common.json | 4 +- .../web/frontend/tests/ui/cross-links.spec.ts | 178 ++++++++++++++++++ docker/web/frontend/tests/ui/dialog.spec.ts | 4 + docker/web/frontend/tests/ui/settle.ts | 22 +++ docs/reference/ui.ko.md | 2 +- docs/reference/ui.md | 2 +- 12 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 docker/web/frontend/tests/ui/cross-links.spec.ts create mode 100644 docker/web/frontend/tests/ui/settle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9e9511..c325b0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- The Components and Vulnerabilities sections link to each other. A component's expanded detail opens the vulnerability list filtered to that component, and a vulnerability's detail opens the component list filtered to its package. Moving between the two lists meant retyping the name into the other section's search box. The links sit in the expanded detail because each table row is itself the expand control, and a control nested inside a control is not announced reliably by screen readers. + +- The fixed-version column reads in a darker green. The previous shade measured 3.77:1 against the light background, below the 4.5:1 minimum. + - Deleting a scan asks for confirmation first, naming the scan it is about to remove. The delete control in the scan table and the one in the top bar's scan menu both removed a scan's output folder on a single click, and because the files are gone from disk with no copy kept, a mis-click could not be taken back. The prompt opens on Cancel, and a confirmed delete says so. Modal dialogs also hold the keyboard now: focus moves into the panel when one opens, Tab stays inside it, and it returns to whatever opened the dialog on close. ### Added diff --git a/docker/web/frontend/src/components/ComponentsTable.tsx b/docker/web/frontend/src/components/ComponentsTable.tsx index e8274a1c..03d4e767 100644 --- a/docker/web/frontend/src/components/ComponentsTable.tsx +++ b/docker/web/frontend/src/components/ComponentsTable.tsx @@ -27,6 +27,9 @@ interface Props { /** License id seeded from a Licenses distribution row; selects that license * in the license filter, leaving the other filters open. */ initialLicense?: string; + /** Open the Vulnerabilities section filtered to this component — the other + * half of the investigation loop (which CVEs does this row stand for?). */ + onPickVulns?: (name: string) => void; } type Sort = { key: ComponentSortKey; dir: SortDir }; @@ -127,6 +130,7 @@ export function ComponentsTable({ truncated, initialQuery, initialLicense, + onPickVulns, }: Props) { const { t } = useTranslation(); const [filters, setFilters] = useState(() => ({ @@ -541,9 +545,28 @@ export function ComponentsTable({ {c.vulnCount ? ( <>
{t("nav.vulnerabilities")}
-
- {c.maxSeverity ? `${t(`severity.${c.maxSeverity}`)} · ` : ""} - {c.vulnCount} +
+ + {c.maxSeverity ? `${t(`severity.${c.maxSeverity}`)} · ` : ""} + {c.vulnCount} + + {/* Into the CVEs behind this row. It sits in the + expanded detail rather than on the risk badge + because the row itself is the toggle control, + and a control inside a control is not announced + reliably. */} + {onPickVulns && ( + + )}
) : null} diff --git a/docker/web/frontend/src/components/NextApp.tsx b/docker/web/frontend/src/components/NextApp.tsx index 95559166..b8163995 100644 --- a/docker/web/frontend/src/components/NextApp.tsx +++ b/docker/web/frontend/src/components/NextApp.tsx @@ -349,10 +349,16 @@ export function NextApp() { } }; - // An Overview risk-bar click routes into the section with that filter applied. + // An Overview risk-bar click, or a name picked out of a result table, routes + // into the section with that filter applied. const handleFilterPick = ( section: SectionId, - filter: { severity?: Severity; tier?: LicenseRiskTier; license?: string }, + filter: { + severity?: Severity; + tier?: LicenseRiskTier; + license?: string; + term?: string; + }, ) => { setSeed({ section, ...filter }); if (loadedIdRef.current) { diff --git a/docker/web/frontend/src/components/ResultSections.tsx b/docker/web/frontend/src/components/ResultSections.tsx index aabe4652..3e950bc3 100644 --- a/docker/web/frontend/src/components/ResultSections.tsx +++ b/docker/web/frontend/src/components/ResultSections.tsx @@ -57,11 +57,17 @@ export function ResultSection({ seedTier?: LicenseRiskTier | ""; /** License id seeded into the Components license filter (Licenses row click). */ seedLicense?: string; - /** Route into a section with a filter pre-applied (the Overview risk bars, - * a Licenses distribution row). */ + /** Route into a section with a filter pre-applied (the Overview risk bars, a + * Licenses distribution row, a component or package name from the table the + * user is reading). */ onPick?: ( section: SectionId, - seed: { severity?: Severity; tier?: LicenseRiskTier; license?: string }, + seed: { + severity?: Severity; + tier?: LicenseRiskTier; + license?: string; + term?: string; + }, ) => void; /** An artifact was produced after the scan (the on-demand SPDX export), so * the owner can refresh the result it holds. */ @@ -83,6 +89,11 @@ export function ResultSection({ truncated={result.sbom?.truncated} initialQuery={searchQuery} initialLicense={seedLicense} + onPickVulns={ + onPick && result.security + ? (name) => onPick("vulnerabilities", { term: name }) + : undefined + } /> ); @@ -92,6 +103,9 @@ export function ResultSection({ security={result.security} initialQuery={searchQuery} initialSeverity={seedSeverity} + onPickComponent={ + onPick ? (name) => onPick("components", { term: name }) : undefined + } /> ) : ( {t("result.noSecurity")} diff --git a/docker/web/frontend/src/components/VulnerabilitiesTable.tsx b/docker/web/frontend/src/components/VulnerabilitiesTable.tsx index 130f266d..1b1b1ea8 100644 --- a/docker/web/frontend/src/components/VulnerabilitiesTable.tsx +++ b/docker/web/frontend/src/components/VulnerabilitiesTable.tsx @@ -34,6 +34,9 @@ interface Props { initialQuery?: string; /** Severity seeded from an Overview severity-bar click (filters on open). */ initialSeverity?: string; + /** Open the Components section filtered to this package — the other half of + * the investigation loop (what does this CVE's package ship under?). */ + onPickComponent?: (name: string) => void; } type Sort = { key: VulnSortKey; dir: SortDir }; @@ -88,7 +91,15 @@ function vulnLinks(v: VulnItem): string[] { } /** Expanded detail for one CVE — CVSS, description and reference links. */ -function VulnDetail({ vuln, links }: { vuln: VulnItem; links: string[] }) { +function VulnDetail({ + vuln, + links, + onPickComponent, +}: { + vuln: VulnItem; + links: string[]; + onPickComponent?: (name: string) => void; +}) { const { t } = useTranslation(); if (vuln.cvss == null && !vuln.description && links.length === 0) { return

{t("result.vulnNoDetail")}

; @@ -134,6 +145,21 @@ function VulnDetail({ vuln, links }: { vuln: VulnItem; links: string[] }) { ) : null} + {/* Back to the package this CVE is against, in the component inventory. + It lives in the expanded detail because the row itself is the toggle + control, and a control nested in a control is not announced reliably. */} + {onPickComponent && vuln.pkg ? ( + + ) : null} ); } @@ -143,7 +169,12 @@ function VulnDetail({ vuln, links }: { vuln: VulnItem; links: string[] }) { * CVSS score, description and reference links already present in the Trivy * report — no extra fetch, no side panel. */ -export function VulnerabilitiesTable({ security, initialQuery, initialSeverity }: Props) { +export function VulnerabilitiesTable({ + security, + initialQuery, + initialSeverity, + onPickComponent, +}: Props) { const { t } = useTranslation(); const items = security.vulnerabilities ?? []; const [openKey, setOpenKey] = useState(null); @@ -324,9 +355,11 @@ export function VulnerabilitiesTable({ security, initialQuery, initialSeverity } {v.installed || "—"} + {/* Fixed version: -700 rather than -600, which measures 3.77 + against the light surface, under the 4.5 minimum. */} {v.fixed ? ( - + {v.fixed} ) : ( @@ -337,7 +370,11 @@ export function VulnerabilitiesTable({ security, initialQuery, initialSeverity } {isOpen && ( - + )} diff --git a/docker/web/frontend/src/locales/en/common.json b/docker/web/frontend/src/locales/en/common.json index afea16a2..7f61e861 100644 --- a/docker/web/frontend/src/locales/en/common.json +++ b/docker/web/frontend/src/locales/en/common.json @@ -260,7 +260,9 @@ "colCurrency": "Version currency", "maliciousBadge": "Malicious package", "maliciousBadgeHint": "Published to attack whoever installs it. Remove it and rotate any credential the build could reach — there is no version to upgrade to.", - "kernelAdvisories": "{{count}} kernel advisories are reported in the security report and are not counted above. Most advisories against a kernel are for subsystems a device never compiled in, and the SBOM cannot tell which." + "kernelAdvisories": "{{count}} kernel advisories are reported in the security report and are not counted above. Most advisories against a kernel are for subsystems a device never compiled in, and the SBOM cannot tell which.", + "viewInComponents": "View {{name}} in Components", + "viewInVulns": "View vulnerabilities for {{name}}" }, "deps": { "loading": "Loading dependencies…", diff --git a/docker/web/frontend/src/locales/ko/common.json b/docker/web/frontend/src/locales/ko/common.json index 377b4763..4425429b 100644 --- a/docker/web/frontend/src/locales/ko/common.json +++ b/docker/web/frontend/src/locales/ko/common.json @@ -260,7 +260,9 @@ "colCurrency": "버전 최신성", "maliciousBadge": "악성 패키지", "maliciousBadgeHint": "설치하는 쪽을 공격하려고 배포된 패키지입니다. 올릴 수 있는 버전이 없으므로 제거하고, 빌드가 접근할 수 있었던 자격 증명을 교체하세요.", - "kernelAdvisories": "커널 관련 권고 {{count}}건은 보안 보고서에 실리며 위 집계에는 넣지 않았습니다. 커널 권고 대부분은 그 장비가 빌드하지 않은 서브시스템에 대한 것이고, SBOM으로는 어느 것인지 가릴 수 없습니다." + "kernelAdvisories": "커널 관련 권고 {{count}}건은 보안 보고서에 실리며 위 집계에는 넣지 않았습니다. 커널 권고 대부분은 그 장비가 빌드하지 않은 서브시스템에 대한 것이고, SBOM으로는 어느 것인지 가릴 수 없습니다.", + "viewInComponents": "컴포넌트에서 {{name}} 보기", + "viewInVulns": "{{name}}의 취약점 보기" }, "deps": { "loading": "의존성을 불러오는 중…", diff --git a/docker/web/frontend/tests/ui/cross-links.spec.ts b/docker/web/frontend/tests/ui/cross-links.spec.ts new file mode 100644 index 00000000..2e21fbb5 --- /dev/null +++ b/docker/web/frontend/tests/ui/cross-links.spec.ts @@ -0,0 +1,178 @@ +// Copyright 2026 SK Telecom Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 + +import AxeBuilder from "@axe-core/playwright"; +import { expect, test, type Page } from "@playwright/test"; + +import { waitForSettled } from "./settle"; + +/** + * The investigation loop between the two result tables: a CVE's expanded detail + * opens Components filtered to its package, and a component's expanded detail + * opens Vulnerabilities filtered to that component. Both reuse the seed + * mechanism the Overview jump cards use, so what is asserted here is the + * routing and the seeded filter — the section the user lands on, and the term + * sitting in its search box. + * + * The links sit in the expanded detail, not on the row's own cells, because + * each row is itself the toggle control and axe rejects a control nested in a + * control (nested-interactive). + */ + +const DONE = { + ok: true, + mode: "SOURCE", + id: "demo_2.1", + results: [{ name: "demo_2.1_bom.json", size: 100 }], + conformance: null, + security: { + total: 2, + counts: { CRITICAL: 1, HIGH: 1, MEDIUM: 0, LOW: 0 }, + vulnerabilities: [ + { + id: "CVE-2024-0001", + severity: "CRITICAL", + pkg: "openssl", + installed: "3.0.0", + fixed: "3.0.1", + cvss: 9.8, + }, + { + id: "CVE-2024-0002", + severity: "HIGH", + pkg: "zlib", + installed: "1.2.11", + fixed: null, + cvss: 7.5, + }, + ], + }, + sbom: { + components: 2, + componentList: [ + { + name: "openssl", + version: "3.0.0", + group: "", + purl: "pkg:generic/openssl@3.0.0", + type: "library", + licenses: ["Apache-2.0"], + maxSeverity: "CRITICAL", + vulnCount: 1, + }, + { + name: "zlib", + version: "1.2.11", + group: "", + purl: "pkg:generic/zlib@1.2.11", + type: "library", + licenses: ["Zlib"], + maxSeverity: "HIGH", + vulnCount: 1, + }, + ], + }, +}; + +const SBOM = { + bomFormat: "CycloneDX", + metadata: { component: { "bom-ref": "root", name: "demo", version: "2.1" } }, + components: [ + { "bom-ref": "o", name: "openssl", version: "3.0.0", type: "library", purl: "o" }, + { "bom-ref": "z", name: "zlib", version: "1.2.11", type: "library", purl: "z" }, + ], + dependencies: [], +}; + +async function openScan(page: Page, section: string) { + await page.route("**/capabilities", (r) => + r.fulfill({ + contentType: "application/json", + body: JSON.stringify({ firmware: false, docker: true }), + }), + ); + await page.route("**/results", (r) => + r.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route("**/scans", (r) => + r.fulfill({ contentType: "application/json", body: "[]" }), + ); + await page.route("**/scan?id=demo_2.1", (r) => + r.fulfill({ contentType: "application/json", body: JSON.stringify(DONE) }), + ); + await page.route("**/file**", (r) => + r.fulfill({ contentType: "application/json", body: JSON.stringify(SBOM) }), + ); + await page.goto(`/?ui=next#/scan/demo_2.1/${section}`); + await page.getByRole("navigation").first().waitFor(); +} + +test("a CVE's detail opens Components filtered to its package", async ({ page }) => { + await openScan(page, "vulnerabilities"); + await expect(page.getByText("CVE-2024-0001")).toBeVisible(); + + await page.getByRole("button", { name: "Show vulnerability details" }).first().click(); + await page.getByRole("button", { name: "View openssl in Components" }).click(); + + await expect + .poll(() => page.evaluate(() => window.location.hash)) + .toBe("#/scan/demo_2.1/components"); + await expect( + page.getByRole("textbox", { name: "Search name, license, type…" }), + ).toHaveValue("openssl"); + // The filter actually narrowed the table, not just the input. + await expect(page.getByRole("cell", { name: "zlib", exact: true })).toHaveCount(0); +}); + +test("a component's detail opens Vulnerabilities filtered to it", async ({ + page, +}) => { + await openScan(page, "components"); + await expect(page.getByRole("cell", { name: "openssl", exact: true })).toBeVisible(); + + // Rows carry role="button" (they are the expand toggle), so the second one is + // zlib's; its detail holds the link into Vulnerabilities. + await page.getByRole("button", { name: "Show component details" }).nth(1).click(); + await page.getByRole("button", { name: "View vulnerabilities for zlib" }).click(); + + await expect + .poll(() => page.evaluate(() => window.location.hash)) + .toBe("#/scan/demo_2.1/vulnerabilities"); + await expect( + page.getByRole("textbox", { name: "Search CVE, component, title" }), + ).toHaveValue("zlib"); + await expect(page.getByText("CVE-2024-0002")).toBeVisible(); + await expect(page.getByText("CVE-2024-0001")).toHaveCount(0); +}); + +test("the round trip lands back on the component it started from", async ({ + page, +}) => { + await openScan(page, "components"); + await page.getByRole("button", { name: "Show component details" }).first().click(); + await page.getByRole("button", { name: "View vulnerabilities for openssl" }).click(); + await expect(page.getByText("CVE-2024-0001")).toBeVisible(); + + await page.getByRole("button", { name: "Show vulnerability details" }).first().click(); + await page.getByRole("button", { name: "View openssl in Components" }).click(); + await expect( + page.getByRole("textbox", { name: "Search name, license, type…" }), + ).toHaveValue("openssl"); + await expect(page.getByRole("cell", { name: "openssl", exact: true })).toBeVisible(); +}); + +test("the cross-links are reachable and accessible", async ({ page }) => { + await openScan(page, "vulnerabilities"); + await page.getByRole("button", { name: "Show vulnerability details" }).first().click(); + const link = page.getByRole("button", { name: "View openssl in Components" }); + + // Keyboard-reachable, with a visible focus ring class applied. + await link.focus(); + await expect(link).toBeFocused(); + await waitForSettled(page.locator("main")); + + const axe = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect(axe.violations).toEqual([]); +}); diff --git a/docker/web/frontend/tests/ui/dialog.spec.ts b/docker/web/frontend/tests/ui/dialog.spec.ts index 593ea639..994911a5 100644 --- a/docker/web/frontend/tests/ui/dialog.spec.ts +++ b/docker/web/frontend/tests/ui/dialog.spec.ts @@ -4,6 +4,8 @@ import AxeBuilder from "@axe-core/playwright"; import { expect, test, type Page } from "@playwright/test"; +import { waitForSettled } from "./settle"; + /** * Modal behaviour that the visual snapshots and axe cannot see: the confirm * step in front of a delete (the files go from disk, so there is no undo), and @@ -137,6 +139,8 @@ test("the prompt is accessible", async ({ page }) => { await page.goto("/?ui=next#/"); await page.getByRole("button", { name: "Delete" }).first().click(); await expect(page.getByRole("dialog")).toBeVisible(); + // The panel fades in; a contrast check taken mid-fade reads blended colours. + await waitForSettled(page.getByRole("dialog")); const axe = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) diff --git a/docker/web/frontend/tests/ui/settle.ts b/docker/web/frontend/tests/ui/settle.ts new file mode 100644 index 00000000..efdf56d9 --- /dev/null +++ b/docker/web/frontend/tests/ui/settle.ts @@ -0,0 +1,22 @@ +// Copyright 2026 SK Telecom Co., Ltd. +// SPDX-License-Identifier: Apache-2.0 + +import type { Locator } from "@playwright/test"; + +/** + * Wait for an element's own animations to finish before measuring it. + * + * Panels mount with `animate-fade-in`, and a contrast check taken mid-fade + * reads the blended colour rather than the settled one — a real 4.6:1 pair + * measures 4.23:1 and axe reports a violation that does not exist once the + * animation ends. Call this after the element is visible and before any axe or + * colour assertion. + */ +export async function waitForSettled(locator: Locator) { + await locator.evaluate(async (el) => { + await Promise.all( + el.getAnimations().map((a) => a.finished.catch(() => undefined)), + ); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + }); +} diff --git a/docs/reference/ui.ko.md b/docs/reference/ui.ko.md index f88384e8..6fdf4e0e 100644 --- a/docs/reference/ui.ko.md +++ b/docs/reference/ui.ko.md @@ -72,7 +72,7 @@ cd ~/sbom-output # 출력 폴더(아무 곳이나 가능) ![개요 — 주의 필요, 수치, 심각도, 바로가기 카드](../images/app-results.png) -**컴포넌트**는 검출된 모든 항목을 나열합니다. 검색과 필터(취약점 있음, 직접 의존만, 검토 필요, 지원 종료, 최신 아님)가 있고, 범위(직접·이행)와 위험(최고 취약점 심각도와 개수) 열을 둡니다. 번들된 OSV 스냅샷에 악성으로 등록된 컴포넌트에는 "악성 패키지" 배지가 다른 배지보다 앞에 붙고, 권고 식별자와 스냅샷 날짜를 툴팁으로 보여줍니다. 상위 지원이 종료된 컴포넌트에는 "지원 종료" 배지가 붙고, 아는 경우 종료 날짜도 함께 표시합니다. 최신 버전이 아닌 컴포넌트는 최신 아님으로 표시하며, deps.dev 보강을 켜면(`STALENESS_ENRICH=true`) 상세에 몇 릴리스 뒤인지와 마지막 릴리스 날짜가 나옵니다([버전 최신성](../concepts/reports-explained.ko.md#버전-최신성) 참고). 대용량 SBOM은 나눠서 표시합니다. 행을 클릭하면 그 자리에서 상세가 펼쳐집니다. PURL, 소스·다운로드 위치, 저작권, 라이선스, 취약점을 보여줍니다. +**컴포넌트**는 검출된 모든 항목을 나열합니다. 검색과 필터(취약점 있음, 직접 의존만, 검토 필요, 지원 종료, 최신 아님)가 있고, 범위(직접·이행)와 위험(최고 취약점 심각도와 개수) 열을 둡니다. 번들된 OSV 스냅샷에 악성으로 등록된 컴포넌트에는 "악성 패키지" 배지가 다른 배지보다 앞에 붙고, 권고 식별자와 스냅샷 날짜를 툴팁으로 보여줍니다. 상위 지원이 종료된 컴포넌트에는 "지원 종료" 배지가 붙고, 아는 경우 종료 날짜도 함께 표시합니다. 최신 버전이 아닌 컴포넌트는 최신 아님으로 표시하며, deps.dev 보강을 켜면(`STALENESS_ENRICH=true`) 상세에 몇 릴리스 뒤인지와 마지막 릴리스 날짜가 나옵니다([버전 최신성](../concepts/reports-explained.ko.md#버전-최신성) 참고). 대용량 SBOM은 나눠서 표시합니다. 행을 클릭하면 그 자리에서 상세가 펼쳐집니다. PURL, 소스·다운로드 위치, 저작권, 라이선스, 취약점을 보여줍니다. 상세에 취약점이 있으면 그 컴포넌트로 필터된 취약점 섹션으로 가는 링크가 함께 나오고, 취약점 상세에는 그 패키지로 필터된 컴포넌트 섹션으로 돌아가는 링크가 있어 이름을 다시 입력하지 않고 두 목록을 오갈 수 있습니다. ![컴포넌트 — 범위·위험 열과 필터](../images/web-ui-components.png) diff --git a/docs/reference/ui.md b/docs/reference/ui.md index 42121869..bd717e83 100644 --- a/docs/reference/ui.md +++ b/docs/reference/ui.md @@ -74,7 +74,7 @@ If a scan finished with reduced analysis — for example cdxgen ran out of Docke ![Overview — needs-attention, counts, severity and jump cards](../images/app-results.png) -**Components** lists everything detected, with search and filters (has vulnerabilities, direct only, needs review, end of life, outdated) and columns for Scope (direct vs transitive) and Risk (the worst vulnerability severity and count). A component the bundled OSV snapshot knows to be a malicious package carries a "Malicious package" badge, ahead of the others, with the advisory id and snapshot date in its tooltip. A component past its upstream end-of-life carries an "End of life" badge, with the EOL date where known. A component that is not on its latest version is marked as outdated; with deps.dev enrichment on (`STALENESS_ENRICH=true`) its detail shows how many releases it is behind and its last-release date (see [Version currency](../concepts/reports-explained.md#version-currency)). Large SBOMs render in pages. Click a row to expand its detail in place — the PURL, source/download location, copyright, licenses and any vulnerabilities. +**Components** lists everything detected, with search and filters (has vulnerabilities, direct only, needs review, end of life, outdated) and columns for Scope (direct vs transitive) and Risk (the worst vulnerability severity and count). A component the bundled OSV snapshot knows to be a malicious package carries a "Malicious package" badge, ahead of the others, with the advisory id and snapshot date in its tooltip. A component past its upstream end-of-life carries an "End of life" badge, with the EOL date where known. A component that is not on its latest version is marked as outdated; with deps.dev enrichment on (`STALENESS_ENRICH=true`) its detail shows how many releases it is behind and its last-release date (see [Version currency](../concepts/reports-explained.md#version-currency)). Large SBOMs render in pages. Click a row to expand its detail in place — the PURL, source/download location, copyright, licenses and any vulnerabilities. Where the detail reports vulnerabilities, it also links into the Vulnerabilities section filtered to that component, and a vulnerability's own detail links back to its package in Components, so an investigation moves between the two lists without retyping the name. ![Components — Scope and Risk columns with filters](../images/web-ui-components.png)