Skip to content

Commit 5fe8b20

Browse files
committed
feat: add vulnerability banner
1 parent 5e6e15d commit 5fe8b20

12 files changed

Lines changed: 306 additions & 79 deletions

File tree

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<script setup lang="ts">
2+
defineProps<{
3+
/** Dependency path from root to vulnerable package (readonly from VulnerabilityTreeResult) */
4+
path: readonly string[]
5+
}>()
6+
7+
const { t } = useI18n()
8+
9+
const isOpen = shallowRef(false)
10+
const popupEl = ref<HTMLElement | null>(null)
11+
const popupPosition = shallowRef<{ top: number; left: number } | null>(null)
12+
13+
// Function ref - captures the element when popup mounts
14+
function setPopupRef(el: unknown) {
15+
popupEl.value = (el as HTMLElement) || null
16+
}
17+
18+
function closePopup() {
19+
isOpen.value = false
20+
}
21+
22+
// Close popup on click outside
23+
onClickOutside(popupEl, () => {
24+
if (isOpen.value) closePopup()
25+
})
26+
27+
// Close popup on ESC or scroll
28+
function handleKeydown(e: KeyboardEvent) {
29+
if (e.key === 'Escape') closePopup()
30+
}
31+
32+
onMounted(() => {
33+
document.addEventListener('keydown', handleKeydown)
34+
window.addEventListener('scroll', closePopup, true)
35+
})
36+
37+
onUnmounted(() => {
38+
document.removeEventListener('keydown', handleKeydown)
39+
window.removeEventListener('scroll', closePopup, true)
40+
})
41+
42+
function togglePopup(event: MouseEvent) {
43+
if (isOpen.value) {
44+
closePopup()
45+
} else {
46+
const button = event.currentTarget as HTMLElement
47+
const rect = button.getBoundingClientRect()
48+
popupPosition.value = {
49+
top: rect.bottom + 4,
50+
left: rect.left,
51+
}
52+
isOpen.value = true
53+
}
54+
}
55+
56+
function getPopupStyle(): Record<string, string> {
57+
if (!popupPosition.value) return {}
58+
return {
59+
top: `${popupPosition.value.top}px`,
60+
left: `${popupPosition.value.left}px`,
61+
}
62+
}
63+
64+
// Parse package string "name@version" into { name, version }
65+
function parsePackageString(pkg: string): { name: string; version: string } {
66+
const atIndex = pkg.lastIndexOf('@')
67+
if (atIndex > 0) {
68+
return { name: pkg.slice(0, atIndex), version: pkg.slice(atIndex + 1) }
69+
}
70+
return { name: pkg, version: '' }
71+
}
72+
</script>
73+
74+
<template>
75+
<div class="relative">
76+
<!-- Path badge button -->
77+
<button
78+
type="button"
79+
class="path-badge font-mono text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400 cursor-pointer transition-all duration-200 ease-out whitespace-nowrap flex items-center gap-1 hover:bg-amber-500/20 hover:border-amber-500/50"
80+
:aria-expanded="isOpen"
81+
@click.stop="togglePopup"
82+
>
83+
<span class="i-carbon-tree-view w-3 h-3" aria-hidden="true" />
84+
<span>{{ t('package.vulnerabilities.path') }}</span>
85+
</button>
86+
87+
<!-- Tree popup -->
88+
<div
89+
v-if="isOpen"
90+
:ref="setPopupRef"
91+
class="fixed z-[100] bg-bg-elevated border border-border rounded-lg shadow-xl p-3 min-w-64 max-w-sm"
92+
:style="getPopupStyle()"
93+
>
94+
<ul class="list-none m-0 p-0 space-y-0.5">
95+
<li
96+
v-for="(pathItem, idx) in path"
97+
:key="idx"
98+
class="font-mono text-xs"
99+
:style="{ paddingLeft: `${idx * 12}px` }"
100+
>
101+
<span v-if="idx > 0" class="text-fg-subtle mr-1">└─</span>
102+
<NuxtLink
103+
:to="{
104+
name: 'package',
105+
params: {
106+
package: [
107+
...parsePackageString(pathItem).name.split('/'),
108+
'v',
109+
parsePackageString(pathItem).version,
110+
],
111+
},
112+
}"
113+
class="hover:underline"
114+
:class="idx === path.length - 1 ? 'text-fg font-medium' : 'text-fg-muted'"
115+
@click="closePopup"
116+
>
117+
{{ pathItem }}
118+
</NuxtLink>
119+
<span v-if="idx === path.length - 1" class="ml-1 text-amber-500">⚠</span>
120+
</li>
121+
</ul>
122+
</div>
123+
</div>
124+
</template>

app/components/PackageVulnerabilityTree.vue

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
<script setup lang="ts">
22
import { SEVERITY_LEVELS } from '#shared/types'
33
import { SEVERITY_COLORS } from '#shared/utils/severity'
4-
import { useVulnerabilityTree } from '~/composables/useVulnerabilityTree'
54
65
const props = defineProps<{
76
packageName: string
87
version: string
98
}>()
109
10+
const { t } = useI18n()
11+
1112
const {
1213
data: vulnTree,
1314
status,
14-
error,
1515
fetch: fetchVulnTree,
1616
} = useVulnerabilityTree(
1717
() => props.packageName,
@@ -27,33 +27,30 @@ const hasVulnerabilities = computed(
2727
() => vulnTree.value && vulnTree.value.vulnerablePackages.length > 0,
2828
)
2929
30-
// Banner
31-
const bannerColor = 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400'
30+
// Banner - amber for better light mode contrast
31+
const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-400'
3232
3333
const summaryText = computed(() => {
3434
if (!vulnTree.value) return ''
3535
const { totalCounts } = vulnTree.value
3636
return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0)
37-
.map(s => `${totalCounts[s]} ${s}`)
37+
.map(s => `${totalCounts[s]} ${t(`package.vulnerabilities.severity.${s}`)}`)
3838
.join(', ')
3939
})
4040
41-
// Styling for each depth level - using design tokens for accessible contrast
41+
// Styling for each depth level - using accessible colors for both themes
4242
const depthStyles = {
4343
root: {
44-
bg: 'bg-yellow-500/5 border-l-2 border-l-yellow-500',
44+
bg: 'bg-amber-500/5 border-l-2 border-l-amber-600',
4545
text: 'text-fg',
46-
tooltip: 'This package',
4746
},
4847
direct: {
49-
bg: 'bg-orange-500/5 border-l-2 border-l-orange-500',
48+
bg: 'bg-amber-500/5 border-l-2 border-l-amber-500',
5049
text: 'text-fg-muted',
51-
tooltip: 'Direct dependency',
5250
},
5351
transitive: {
54-
bg: 'bg-orange-500/5 border-l-2 border-l-orange-700',
52+
bg: 'bg-amber-500/5 border-l-2 border-l-amber-400',
5553
text: 'text-fg-muted',
56-
tooltip: 'Transitive dependency (indirect)',
5754
},
5855
} as const
5956
@@ -67,7 +64,11 @@ function getDepthStyle(depth: string | undefined) {
6764
</script>
6865

6966
<template>
70-
<section v-if="status === 'success' && hasVulnerabilities" aria-labelledby="vuln-tree-heading">
67+
<section
68+
v-if="status === 'success' && hasVulnerabilities"
69+
aria-labelledby="vuln-tree-heading"
70+
class="relative"
71+
>
7172
<!-- Collapsible vulnerability banner -->
7273
<div role="alert" class="rounded-lg border overflow-hidden" :class="bannerColor">
7374
<!-- Header -->
@@ -81,9 +82,17 @@ function getDepthStyle(depth: string | undefined) {
8182
<div class="flex items-center gap-2 min-w-0">
8283
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
8384
<span class="font-mono text-sm font-medium truncate">
84-
{{ vulnTree!.totalCounts.total }}
85-
{{ vulnTree!.totalCounts.total === 1 ? 'vulnerability' : 'vulnerabilities' }}
86-
in {{ vulnTree!.vulnerablePackages.length }}/{{ vulnTree!.totalPackages }} packages
85+
{{
86+
t(
87+
'package.vulnerabilities.tree_found',
88+
{
89+
vulns: vulnTree!.totalCounts.total,
90+
packages: vulnTree!.vulnerablePackages.length,
91+
total: vulnTree!.totalPackages,
92+
},
93+
vulnTree!.totalCounts.total,
94+
)
95+
}}
8796
</span>
8897
</div>
8998
<div class="flex items-center gap-2 shrink-0">
@@ -106,26 +115,16 @@ function getDepthStyle(depth: string | undefined) {
106115
:class="getDepthStyle(pkg.depth).bg"
107116
>
108117
<div class="flex items-center justify-between gap-2 mb-2">
109-
<div class="flex items-center gap-2 min-w-0">
110-
<!-- Depth indicator icon with tooltip -->
111-
<span
112-
v-if="pkg.depth === 'transitive'"
113-
class="i-carbon-tree-view w-3.5 h-3.5 shrink-0 cursor-help"
114-
:class="getDepthStyle(pkg.depth).text"
115-
:title="getDepthStyle(pkg.depth).tooltip"
116-
/>
117-
<span
118-
v-else-if="pkg.depth === 'direct'"
119-
class="i-carbon-arrow-right w-3.5 h-3.5 shrink-0 cursor-help"
120-
:class="getDepthStyle(pkg.depth).text"
121-
:title="getDepthStyle(pkg.depth).tooltip"
122-
/>
118+
<div class="flex items-center gap-2 min-w-0 relative">
119+
<!-- Path badge - click to show tree popup -->
120+
<DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" />
121+
123122
<NuxtLink
124123
:to="{
125124
name: 'package',
126125
params: { package: [...pkg.name.split('/'), 'v', pkg.version] },
127126
}"
128-
class="font-mono text-sm font-medium hover:underline truncate"
127+
class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0"
129128
:class="getDepthStyle(pkg.depth).text"
130129
>
131130
{{ pkg.name }}@{{ pkg.version }}
@@ -138,7 +137,7 @@ function getDepthStyle(depth: string | undefined) {
138137
class="px-1.5 py-0.5 text-[10px] font-mono rounded border"
139138
:class="SEVERITY_COLORS[s]"
140139
>
141-
{{ pkg.counts[s] }} {{ s }}
140+
{{ pkg.counts[s] }} {{ t(`package.vulnerabilities.severity.${s}`) }}
142141
</span>
143142
</div>
144143
</div>
@@ -160,7 +159,7 @@ function getDepthStyle(depth: string | undefined) {
160159
<span class="truncate">{{ vuln.summary }}</span>
161160
</li>
162161
<li v-if="pkg.vulnerabilities.length > 2" class="text-xs text-fg-subtle">
163-
+{{ pkg.vulnerabilities.length - 2 }} more
162+
{{ t('package.vulnerabilities.more', { count: pkg.vulnerabilities.length - 2 }) }}
164163
</li>
165164
</ul>
166165
</li>
@@ -172,13 +171,26 @@ function getDepthStyle(depth: string | undefined) {
172171
class="w-full px-4 py-2 text-xs font-mono text-fg-muted hover:text-fg border-t border-border transition-colors duration-200"
173172
@click="showAllPackages = true"
174173
>
175-
show all {{ vulnTree!.vulnerablePackages.length }} affected packages
174+
{{
175+
t('package.vulnerabilities.show_all_packages', {
176+
count: vulnTree!.vulnerablePackages.length,
177+
})
178+
}}
176179
</button>
180+
181+
<!-- Warning if some queries failed -->
182+
<div
183+
v-if="vulnTree!.failedQueries"
184+
class="px-4 py-2 text-xs text-fg-subtle border-t border-border flex items-center gap-2"
185+
>
186+
<span class="i-carbon-warning w-3 h-3" aria-hidden="true" />
187+
<span>{{ t('package.vulnerabilities.packages_failed', vulnTree!.failedQueries) }}</span>
188+
</div>
177189
</div>
178190
</div>
179191
</section>
180192

181-
<!-- Loading state -->
193+
<!-- Loading state - muted -->
182194
<section
183195
v-else-if="status === 'pending' || status === 'idle'"
184196
aria-labelledby="vuln-tree-loading"
@@ -189,34 +201,41 @@ function getDepthStyle(depth: string | undefined) {
189201
class="i-carbon-circle-dash w-4 h-4 animate-spin motion-reduce:animate-none text-fg-subtle"
190202
aria-hidden="true"
191203
/>
192-
<span class="text-sm text-fg-subtle">Scanning dependency tree...</span>
204+
<span class="text-sm text-fg-muted">{{ t('package.vulnerabilities.scanning_tree') }}</span>
193205
</div>
194206
</div>
195207
</section>
196208

197-
<!-- No vulnerabilities found -->
209+
<!-- No vulnerabilities found - muted, not attention-grabbing -->
198210
<section
199211
v-else-if="status === 'success' && !hasVulnerabilities"
200212
aria-labelledby="vuln-tree-success"
201213
>
202-
<div class="rounded-lg border border-green-500/30 bg-green-500/10 px-4 py-3">
214+
<div class="rounded-lg border border-border bg-bg-subtle px-4 py-3">
203215
<div class="flex items-center gap-2">
204-
<span class="i-carbon-checkmark-filled w-4 h-4 text-green-400" aria-hidden="true" />
205-
<span class="text-sm text-green-400">
206-
No known vulnerabilities in {{ vulnTree?.totalPackages ?? 0 }} packages
216+
<span class="i-carbon-checkmark w-4 h-4 text-fg-subtle" aria-hidden="true" />
217+
<span class="text-sm text-fg-muted">
218+
{{ t('package.vulnerabilities.no_known', { count: vulnTree?.totalPackages ?? 0 }) }}
207219
</span>
208220
</div>
221+
<!-- Warning if some queries failed -->
222+
<div
223+
v-if="vulnTree?.failedQueries"
224+
class="flex items-center gap-2 mt-2 text-xs text-fg-subtle"
225+
>
226+
<span class="i-carbon-warning w-3 h-3" aria-hidden="true" />
227+
<span>{{ t('package.vulnerabilities.packages_failed', vulnTree.failedQueries) }}</span>
228+
</div>
209229
</div>
210230
</section>
211231

212-
<!-- Error state -->
232+
<!-- Error state - subtle, not alarming -->
213233
<section v-else-if="status === 'error'" aria-labelledby="vuln-tree-error">
214-
<div class="rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3">
234+
<div class="rounded-lg border border-border bg-bg-subtle px-4 py-3">
215235
<div class="flex items-center gap-2">
216-
<span class="i-carbon-warning-alt w-4 h-4 text-red-400" aria-hidden="true" />
217-
<span class="text-sm text-red-400">
218-
Failed to scan vulnerabilities
219-
<template v-if="error?.message">: {{ error.message }}</template>
236+
<span class="i-carbon-warning w-4 h-4 text-fg-subtle" aria-hidden="true" />
237+
<span class="text-sm text-fg-muted">
238+
{{ t('package.vulnerabilities.scan_failed') }}
220239
</span>
221240
</div>
222241
</div>

app/composables/useVulnerabilityTree.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { VulnerabilityTreeResult } from '#shared/types/osv'
2-
import { encodePackageName } from './useNpmRegistry'
32

43
/**
54
* Shared composable for vulnerability tree data.
@@ -12,7 +11,7 @@ export function useVulnerabilityTree(
1211
// Build a stable key from the current values
1312
const name = toValue(packageName)
1413
const ver = toValue(version)
15-
const key = `vuln-tree:${name}@${ver}`
14+
const key = `vuln-tree:v1:${name}@${ver}`
1615

1716
// Use useState for SSR-safe caching across components
1817
const data = useState<VulnerabilityTreeResult | null>(key, () => null)

0 commit comments

Comments
 (0)