diff --git a/i18n/english.js b/i18n/english.js index 9d8adf11..cf854f10 100644 --- a/i18n/english.js +++ b/i18n/english.js @@ -271,6 +271,18 @@ const ui = { lockedNavigation: { next: "Next", prev: "Prev" + }, + warnings: { + title: "Warnings", + totalWarnings: "warnings", + totalPackages: "packages affected", + noWarnings: "No warnings found", + docs: "docs", + packages: "packages", + occurrences: "occurrences", + critical: "Critical", + warning: "Warning", + information: "Information" } }; diff --git a/i18n/french.js b/i18n/french.js index 904b082b..b0b151e6 100644 --- a/i18n/french.js +++ b/i18n/french.js @@ -271,6 +271,18 @@ const ui = { lockedNavigation: { next: "Suivant", prev: "Précédent" + }, + warnings: { + title: "Avertissements", + totalWarnings: "avertissements", + totalPackages: "packages concernés", + noWarnings: "Aucun avertissement trouvé", + docs: "docs", + packages: "packages", + occurrences: "occurrences", + critical: "Critique", + warning: "Avertissement", + information: "Information" } }; diff --git a/public/components/navigation/navigation.js b/public/components/navigation/navigation.js index acaf968c..e6b8ff99 100644 --- a/public/components/navigation/navigation.js +++ b/public/components/navigation/navigation.js @@ -7,7 +7,8 @@ const kAvailableView = new Set([ "network--view", "home--view", "search--view", - "settings--view" + "settings--view", + "warnings--view" ]); export class ViewNavigation { @@ -58,6 +59,10 @@ export class ViewNavigation { this.onNavigationSelected(this.menus.get("search--view")); break; } + case hotkeys.warnings: { + this.onNavigationSelected(this.menus.get("warnings--view")); + break; + } } }); } @@ -77,6 +82,10 @@ export class ViewNavigation { this.setAnchor(menuName); this.activeMenu = selectedNav; + + if (menuName === "network--view") { + window.dispatchEvent(new CustomEvent(EVENTS.NETWORK_VIEW_SHOWED, { composed: true })); + } } disableActiveMenu() { @@ -85,6 +94,10 @@ export class ViewNavigation { this.activeMenu.classList.remove("active"); view.classList.add("hidden"); + + if (menuName === "network--view") { + window.dispatchEvent(new CustomEvent(EVENTS.NETWORK_VIEW_HID, { composed: true })); + } } getAnchor() { diff --git a/public/components/root-selector/root-selector.js b/public/components/root-selector/root-selector.js new file mode 100644 index 00000000..90aff032 --- /dev/null +++ b/public/components/root-selector/root-selector.js @@ -0,0 +1,198 @@ +// Import Third-party Dependencies +import { LitElement, html, css, nothing } from "lit"; + +// Import Internal Dependencies +import { EVENTS } from "../../core/events.js"; + +export class RootSelector extends LitElement { + static properties = { + secureDataSet: { attribute: false }, + _open: { state: true } + }; + + static styles = css` + :host { + position: relative; + display: inline-block; + color: #1a1a2e; + } + + :host-context(body.dark) { + color: rgb(255 255 255 / 87%); + } + + .selector-btn { + display: flex; + align-items: center; + gap: 4px; + background: white; + border: 1.5px solid rgb(55 34 175 / 20%); + border-radius: 8px; + padding: 5px 10px; + cursor: pointer; + font-family: mononoki, monospace; + font-size: 13px; + color: #1a1a2e; + transition: background 0.12s, border-color 0.12s; + white-space: nowrap; + } + + .selector-btn:hover { + background: #f0eeff; + border-color: rgb(55 34 175 / 40%); + } + + :host-context(body.dark) .selector-btn { + background: #1e1c2e; + border-color: rgb(163 148 255 / 25%); + color: rgb(255 255 255 / 87%); + } + + :host-context(body.dark) .selector-btn:hover { + background: #2a2640; + border-color: rgb(163 148 255 / 45%); + } + + .pkg-version { + color: #7c6fff; + } + + :host-context(body.dark) .pkg-version { + color: #a394ff; + } + + .chevron { + font-size: 9px; + opacity: 0.5; + margin-left: 2px; + } + + .dropdown { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 100%; + background: white; + border: 1px solid rgb(0 0 0 / 10%); + border-radius: 8px; + box-shadow: 0 8px 24px rgb(0 0 0 / 10%); + z-index: 100; + overflow: hidden; + padding: 4px 0; + } + + :host-context(body.dark) .dropdown { + background: #1e1c2e; + border-color: rgb(255 255 255 / 10%); + box-shadow: 0 8px 24px rgb(0 0 0 / 40%); + } + + .pkg-item { + display: flex; + align-items: center; + gap: 2px; + padding: 7px 14px; + font-family: mononoki, monospace; + font-size: 12px; + color: #1a1a2e; + cursor: pointer; + white-space: nowrap; + transition: background 0.1s; + } + + :host-context(body.dark) .pkg-item { + color: rgb(255 255 255 / 87%); + } + + .pkg-item:hover { + background: rgb(124 111 255 / 8%); + } + + :host-context(body.dark) .pkg-item:hover { + background: rgb(163 148 255 / 10%); + } + + .item-version { + color: #7c6fff; + } + + :host-context(body.dark) .item-version { + color: #a394ff; + } + `; + + connectedCallback() { + super.connectedCallback(); + this._handleOutsideClick = (event) => { + if (!event.composedPath().includes(this)) { + this._open = false; + } + }; + + document.addEventListener("click", this._handleOutsideClick); + } + + disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener("click", this._handleOutsideClick); + } + + #switchTo(spec) { + window.dispatchEvent(new CustomEvent(EVENTS.ROOT_SWITCH, { + detail: { spec } + })); + this._open = false; + } + + render() { + if (!this.secureDataSet) { + return nothing; + } + + const rootEntry = this.secureDataSet.linker.get(0); + if (!rootEntry) { + return nothing; + } + + const others = (window.cachedSpecs ?? []).filter( + (pkg) => pkg.spec !== window.activePackage + ); + + return html` + + ${this._open && others.length > 0 ? html` + + ` : nothing} + `; + } +} + +customElements.define("root-selector", RootSelector); diff --git a/public/components/views/settings/settings.js b/public/components/views/settings/settings.js index ed8ce20f..abae9ae9 100644 --- a/public/components/views/settings/settings.js +++ b/public/components/views/settings/settings.js @@ -18,7 +18,8 @@ const kDefaultHotKeys = { settings: "S", wiki: "W", lock: "L", - search: "F" + search: "F", + warnings: "A" }; const kShortcutInputTargetIds = new Set(Object.keys(kDefaultHotKeys)); diff --git a/public/components/views/warnings/warnings.js b/public/components/views/warnings/warnings.js new file mode 100644 index 00000000..ed469467 --- /dev/null +++ b/public/components/views/warnings/warnings.js @@ -0,0 +1,491 @@ +// Import Third-party Dependencies +import { LitElement, html, css, nothing } from "lit"; + +// Import Internal Dependencies +import { EVENTS } from "../../../core/events.js"; +import { currentLang } from "../../../common/utils.js"; +import "../../root-selector/root-selector.js"; + +// CONSTANTS +const kSeverityOrder = ["Critical", "Warning", "Information"]; +const kSeverityColors = { + Critical: "#ef4444", + Warning: "#f97316", + Information: "#3b82f6" +}; +const kDocsBaseUrl = "https://github.com/NodeSecure/js-x-ray/blob/master/docs"; + +function buildWarningsData(secureDataSet) { + const nodeIdBySpec = new Map(); + for (const [nodeId, entry] of secureDataSet.linker) { + const spec = `${entry.name}@${entry.version}`; + if (!nodeIdBySpec.has(spec)) { + nodeIdBySpec.set(spec, nodeId); + } + } + + const byKind = new Map(); + for (const [packageName, dependency] of Object.entries(secureDataSet.data.dependencies)) { + for (const [version, versionData] of Object.entries(dependency.versions)) { + const filteredWarnings = versionData.warnings.filter( + (warning) => !window.settings.config.ignore.warnings.has(warning.kind) + ); + + for (const warning of filteredWarnings) { + const { kind, severity } = warning; + if (!byKind.has(kind)) { + byKind.set(kind, { kind, severity, packages: new Map() }); + } + + const kindData = byKind.get(kind); + const spec = `${packageName}@${version}`; + const existing = kindData.packages.get(spec) ?? { + name: packageName, + version, + nodeId: nodeIdBySpec.get(spec), + count: 0 + }; + existing.count++; + kindData.packages.set(spec, existing); + } + } + } + + const grouped = Object.fromEntries(kSeverityOrder.map((severity) => [severity, []])); + for (const kindData of byKind.values()) { + const list = grouped[kindData.severity]; + if (list) { + list.push(kindData); + } + } + + for (const list of Object.values(grouped)) { + list.sort((kindA, kindB) => { + const totalA = [...kindA.packages.values()].reduce((sum, pkg) => sum + pkg.count, 0); + const totalB = [...kindB.packages.values()].reduce((sum, pkg) => sum + pkg.count, 0); + + return totalB - totalA; + }); + } + + let totalWarnings = 0; + const affectedSpecs = new Set(); + for (const kindData of byKind.values()) { + for (const [spec, pkg] of kindData.packages) { + totalWarnings += pkg.count; + affectedSpecs.add(spec); + } + } + + return { grouped, totalWarnings, totalPackages: affectedSpecs.size }; +} + +function countBySeverity(grouped) { + return Object.fromEntries( + kSeverityOrder.map((severity) => [ + severity, + grouped[severity].reduce( + (sum, kindData) => sum + [...kindData.packages.values()].reduce((s, pkg) => s + pkg.count, 0), + 0 + ) + ]) + ); +} + +export class WarningsView extends LitElement { + static properties = { + secureDataSet: { attribute: false } + }; + + static styles = css` + [class^="icon-"]::before, [class*=" icon-"]::before { + font-family: fontello; + font-style: normal; + font-weight: normal; + display: inline-block; + text-decoration: inherit; + text-align: center; + font-variant: normal; + text-transform: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + .icon-warning::before { content: '\\e80e'; } + + :host { + display: block; + height: 100%; + overflow-y: auto; + background: var(--bg, #f8f7ff); + font-family: Roboto, sans-serif; + color: #1a1a2e; + } + + :host-context(body.dark) { + --bg: var(--dark-theme-gray); + + color: rgb(255 255 255 / 87%); + } + + .warnings-header { + padding: 24px 32px 20px; + border-bottom: 1px solid rgb(0 0 0 / 7%); + } + + :host-context(body.dark) .warnings-header { + border-bottom-color: rgb(255 255 255 / 7%); + } + + .warnings-header--top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; + } + + .warnings-header h1 { + font-size: 20px; + font-weight: 700; + margin: 0 0 4px; + display: flex; + align-items: center; + gap: 8px; + } + + .warnings-header h1 i { + font-size: 18px; + color: #f97316; + } + + .warnings-subtitle { + font-size: 13px; + color: #7a7595; + margin: 0 0 16px; + } + + :host-context(body.dark) .warnings-subtitle { + color: rgb(255 255 255 / 45%); + } + + .severity-pills { + display: flex; + gap: 10px; + flex-wrap: wrap; + } + + .severity-pill { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + font-weight: 600; + padding: 3px 10px; + border-radius: 20px; + border: 1.5px solid currentcolor; + } + + .severity-pill .dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentcolor; + flex-shrink: 0; + } + + .severity-pill.critical { color: #ef4444; } + .severity-pill.warning { color: #f97316; } + .severity-pill.info { color: #3b82f6; } + .severity-pill.zero { opacity: 0.35; } + + .warnings-body { + padding: 24px 32px; + display: flex; + flex-direction: column; + gap: 32px; + } + + .severity-section h2 { + font-size: 11px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + margin: 0 0 12px; + display: flex; + align-items: center; + gap: 6px; + } + + .severity-section h2 .section-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .kind-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .kind-card { + background: white; + border-radius: 10px; + border: 1px solid rgb(0 0 0 / 7%); + overflow: hidden; + } + + :host-context(body.dark) .kind-card { + background: #1e1c2e; + border-color: rgb(255 255 255 / 7%); + } + + .kind-card--header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px 10px; + } + + .kind-name { + font-size: 14px; + font-weight: 600; + flex: 1; + font-family: mononoki, monospace; + } + + .severity-badge { + font-size: 10px; + font-weight: 700; + padding: 2px 7px; + border-radius: 4px; + color: white; + letter-spacing: 0.5px; + text-transform: uppercase; + } + + .docs-link { + font-size: 11px; + color: #7c6fff; + text-decoration: none; + opacity: 0.8; + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + } + + .docs-link:hover { opacity: 1; } + + :host-context(body.dark) .docs-link { color: #a394ff; } + + .kind-card--meta { + font-size: 11px; + color: #7a7595; + padding: 0 16px 10px; + } + + :host-context(body.dark) .kind-card--meta { + color: rgb(255 255 255 / 40%); + } + + .kind-card--packages { + border-top: 1px solid rgb(0 0 0 / 5%); + padding: 6px 0; + } + + :host-context(body.dark) .kind-card--packages { + border-top-color: rgb(255 255 255 / 5%); + } + + .pkg-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 16px; + cursor: pointer; + transition: background 0.12s; + } + + .pkg-row:hover { + background: rgb(124 111 255 / 6%); + } + + :host-context(body.dark) .pkg-row:hover { + background: rgb(163 148 255 / 8%); + } + + .pkg-name { + font-size: 12px; + font-family: mononoki, monospace; + flex: 1; + } + + .pkg-name .version { + color: #7c6fff; + } + + :host-context(body.dark) .pkg-name .version { + color: #a394ff; + } + + .pkg-count { + font-size: 11px; + font-weight: 600; + color: #7a7595; + background: rgb(0 0 0 / 5%); + border-radius: 4px; + padding: 1px 6px; + flex-shrink: 0; + } + + :host-context(body.dark) .pkg-count { + background: rgb(255 255 255 / 7%); + color: rgb(255 255 255 / 45%); + } + + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 80px 32px; + color: #7a7595; + text-align: center; + } + + .empty-state .checkmark { + font-size: 40px; + color: #22c55e; + } + + .empty-state p { + font-size: 15px; + margin: 0; + } + `; + + #onPackageClick(nodeId) { + if (nodeId === undefined) { + return; + } + + window.dispatchEvent(new CustomEvent(EVENTS.TREE_NODE_CLICK, { + detail: { nodeId } + })); + } + + #renderKindCard(kindData) { + const i18n = window.i18n[currentLang()]; + const packages = [...kindData.packages.values()]; + const totalCount = packages.reduce((sum, pkg) => sum + pkg.count, 0); + const color = kSeverityColors[kindData.severity] ?? "#6b7280"; + const severityLabel = i18n.warnings[kindData.severity.toLowerCase()]; + const docsLabel = i18n.warnings.docs; + + return html` +
+
+ ${kindData.kind} + ${severityLabel} + event.stopPropagation()} + >${docsLabel} ↗ +
+
+ ${packages.length} ${i18n.warnings.packages} · ${totalCount} ${i18n.warnings.occurrences} +
+
+ ${packages.sort((pkgA, pkgB) => pkgB.count - pkgA.count).map((pkg) => html` +
this.#onPackageClick(pkg.nodeId)}> + ${pkg.name}@${pkg.version} + ×${pkg.count} +
+ `)} +
+
+ `; + } + + #renderSeveritySection(severity, kindList, i18n) { + if (kindList.length === 0) { + return nothing; + } + + const color = kSeverityColors[severity]; + const label = i18n.warnings[severity.toLowerCase()]; + + return html` +
+

+ + ${label} +

+
+ ${kindList.map((kindData) => this.#renderKindCard(kindData))} +
+
+ `; + } + + render() { + if (!this.secureDataSet) { + return nothing; + } + + const i18n = window.i18n[currentLang()]; + const { grouped, totalWarnings, totalPackages } = buildWarningsData(this.secureDataSet); + const bySeverity = countBySeverity(grouped); + const hasWarnings = totalWarnings > 0; + + return html` +
+
+

+ + ${i18n.warnings.title} +

+ +
+

+ ${totalWarnings} ${i18n.warnings.totalWarnings} · ${totalPackages} ${i18n.warnings.totalPackages} +

+
+ ${kSeverityOrder.map((severity) => { + const count = bySeverity[severity]; + const pillClass = severity === "Information" ? "info" : severity.toLowerCase(); + + return html` + + + ${count} ${i18n.warnings[severity.toLowerCase()]} + + `; + })} +
+
+
+ ${hasWarnings + ? kSeverityOrder.map( + (severity) => this.#renderSeveritySection(severity, grouped[severity], i18n) + ) + : html` +
+ +

${i18n.warnings.noWarnings}

+
+ ` + } +
+ `; + } +} + +customElements.define("warnings-view", WarningsView); diff --git a/public/core/events.js b/public/core/events.js index 04421754..441ee458 100644 --- a/public/core/events.js +++ b/public/core/events.js @@ -15,5 +15,6 @@ export const EVENTS = { DRILL_BACK: "drill-back", DRILL_SWITCH: "drill-switch", ROOT_SWITCH: "root-switch", - ROOT_REMOVE: "root-remove" + ROOT_REMOVE: "root-remove", + WARNINGS_PACKAGE_CLICK: "warnings-package-click" }; diff --git a/public/main.js b/public/main.js index 92e3d5b2..98e35a90 100644 --- a/public/main.js +++ b/public/main.js @@ -13,6 +13,8 @@ import "./components/search-command/search-command.js"; import { Settings } from "./components/views/settings/settings.js"; import { HomeView } from "./components/views/home/home.js"; import "./components/views/search/search.js"; +import "./components/views/warnings/warnings.js"; +import "./components/root-selector/root-selector.js"; import "./components/network-breadcrumb/network-breadcrumb.js"; import { NetworkNavigation } from "./core/network-navigation.js"; import { i18n } from "./core/i18n.js"; @@ -27,12 +29,15 @@ let secureDataSet; let nsn; let homeView; let searchview; +let warningsView; +let viewAfterSwitch = null; let drillBreadcrumb; let packageInfoOpened = false; const drillStack = []; document.addEventListener("DOMContentLoaded", async() => { searchview = document.querySelector("search-view"); + warningsView = document.querySelector("warnings-view"); window.cachedSpecs = []; window.locker = null; @@ -44,15 +49,24 @@ document.addEventListener("DOMContentLoaded", async() => { // update searchview after window.i18n is set searchview.requestUpdate(); - document.body.appendChild( - utils.createDOMElement("div", { - classList: ["search-shortcut-hint"], - attributes: { id: "search-shortcut-hint" }, - childs: [ - utils.createDOMElement("kbd", { text: kSearchShortcut }) - ] - }) - ); + const isNetworkViewActive = document.getElementById("network--view").classList.contains("hidden") === false; + const searchShortcutHint = utils.createDOMElement("div", { + classList: isNetworkViewActive ? ["search-shortcut-hint"] : ["search-shortcut-hint", "hidden"], + attributes: { id: "search-shortcut-hint" }, + childs: [ + utils.createDOMElement("kbd", { text: kSearchShortcut }) + ] + }); + document.body.appendChild(searchShortcutHint); + + window.addEventListener(EVENTS.NETWORK_VIEW_HID, () => { + searchShortcutHint.classList.add("hidden"); + }); + window.addEventListener(EVENTS.NETWORK_VIEW_SHOWED, () => { + if (!document.getElementById("network--view").classList.contains("hidden")) { + searchShortcutHint.classList.remove("hidden"); + } + }); drillBreadcrumb = document.querySelector("network-breadcrumb"); drillBreadcrumb.addEventListener(EVENTS.DRILL_RESET, resetDrill); @@ -67,6 +81,11 @@ document.addEventListener("DOMContentLoaded", async() => { drillBreadcrumb.addEventListener(EVENTS.ROOT_SWITCH, function handleRootSwitch(event) { window.socket.commands.search(event.detail.spec); }); + + window.addEventListener(EVENTS.ROOT_SWITCH, function handleGlobalRootSwitch(event) { + viewAfterSwitch = window.navigation.activeMenu?.getAttribute("data-menu") ?? null; + window.socket.commands.search(event.detail.spec); + }); drillBreadcrumb.addEventListener(EVENTS.ROOT_REMOVE, function handleRootRemove() { const specToRemove = window.activePackage; const nextPackage = drillBreadcrumb.packages[0]; @@ -76,11 +95,21 @@ document.addEventListener("DOMContentLoaded", async() => { else { window.navigation.hideMenu("network--view"); window.navigation.hideMenu("home--view"); + window.navigation.hideMenu("warnings--view"); window.navigation.setNavByName("search--view"); } window.socket.commands.remove(specToRemove); }); + warningsView.addEventListener("click", (event) => { + const clickedRow = event.composedPath().find( + (el) => el instanceof Element && el.classList.contains("pkg-row") + ); + if (!clickedRow) { + PackageInfo.close(); + } + }); + await init(); window.dispatchEvent( new CustomEvent(EVENTS.SETTINGS_SAVED, { @@ -112,7 +141,15 @@ async function onSocketPayload(event) { const { name, version } = payload.rootDependency; window.activePackage = name + "@" + version; - await init({ navigateToNetworkView: true }); + const targetView = viewAfterSwitch; + viewAfterSwitch = null; + + await init({ navigateToNetworkView: targetView === null }); + + if (targetView !== null && targetView !== "network--view") { + window.navigation.setNavByName(targetView); + } + dispatchSearchCommandInit(); } @@ -274,6 +311,8 @@ async function init(options = {}) { window.navigation.showMenu("network--view"); window.navigation.showMenu("home--view"); + window.navigation.showMenu("warnings--view"); + warningsView.secureDataSet = secureDataSet; window.vulnerabilityStrategy = secureDataSet.data.vulnerabilityStrategy; @@ -342,6 +381,7 @@ async function loadDataSet() { if (secureDataSet.data === null) { window.navigation.hideMenu("network--view"); window.navigation.hideMenu("home--view"); + window.navigation.hideMenu("warnings--view"); window.navigation.setNavByName("search--view"); return false; diff --git a/views/index.html b/views/index.html index 84e35e22..a791694a 100644 --- a/views/index.html +++ b/views/index.html @@ -58,6 +58,10 @@ +
  • + + +
  • @@ -89,6 +93,7 @@ + +
    + + +