This commit is contained in:
2026-01-02 15:29:43 +00:00
parent 1cbf6c5d9e
commit c6edad4402
34 changed files with 2842 additions and 449 deletions
+7
View File
@@ -0,0 +1,7 @@
# Backend API URL (used by Vite dev server proxy)
# Change this if your gohoarder backend is running on a different port
VITE_BACKEND_URL=http://localhost:8080
# Frontend dev server port
# The Vite development server will run on this port
VITE_PORT=5173
+70 -23
View File
@@ -110,10 +110,10 @@
<span class="text-xs text-muted-foreground">{{ getChartLabel(index) }}</span>
</div>
</div>
<div class="mt-4 text-center">
<div v-if="chartLoading || chartData.length === 0" class="mt-4 text-center">
<p class="text-sm text-muted-foreground">
<i class="fas fa-info-circle mr-1"></i>
Chart data will be available once backend API exposes time-series statistics
{{ chartLoading ? 'Loading chart data...' : 'No download activity in this period' }}
</p>
</div>
</CardContent>
@@ -121,7 +121,7 @@
<!-- Recent Packages -->
<Card><CardContent class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-4">
<h3 class="text-xl font-semibold text-gray-900 mb-4">
<i class="fas fa-clock mr-2"></i>Recent Packages
</h3>
<div v-if="packages.length === 0" class="text-center py-8 text-gray-500">
@@ -178,13 +178,15 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import axios from 'axios'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const { packages, stats, loading, error } = storeToRefs(store)
@@ -198,21 +200,69 @@ const chartPeriods = [
{ value: '30day', label: '30 Days' },
]
// Mock chart data - will be replaced with real API data
const chartData = computed(() => {
// Generate sample data based on selected period
const periods: Record<string, number[]> = {
'1h': [12, 19, 15, 25, 22, 30, 28, 32, 35, 30, 28, 25],
'1day': Array.from({ length: 24 }, () => Math.floor(Math.random() * 50) + 10),
'7day': Array.from({ length: 7 }, () => Math.floor(Math.random() * 100) + 20),
'30day': Array.from({ length: 30 }, () => Math.floor(Math.random() * 80) + 15),
// Time-series data from API
interface TimeSeriesDataPoint {
timestamp: string
value: number
}
interface TimeSeriesStats {
period: string
registry: string
data_points: TimeSeriesDataPoint[]
}
const timeSeriesData = ref<TimeSeriesStats | null>(null)
const chartLoading = ref(false)
// Fetch time-series data from API
async function fetchTimeSeriesData() {
chartLoading.value = true
try {
const response = await axios.get(`/api/stats/timeseries?period=${selectedPeriod.value}`)
timeSeriesData.value = response.data
} catch (err) {
console.error('Failed to fetch time-series data:', err)
timeSeriesData.value = null
} finally {
chartLoading.value = false
}
return periods[selectedPeriod.value] || periods['1day']
}
// Extract chart values from time-series data
const chartData = computed(() => {
if (!timeSeriesData.value || !timeSeriesData.value.data_points) {
return []
}
return timeSeriesData.value.data_points.map(point => point.value)
})
const maxChartValue = computed(() => Math.max(...chartData.value))
const maxChartValue = computed(() => {
if (chartData.value.length === 0) return 100
const max = Math.max(...chartData.value)
return max === 0 ? 100 : max
})
function getChartLabel(index: number): string {
// Use timestamp from data if available
if (timeSeriesData.value && timeSeriesData.value.data_points[index]) {
const date = new Date(timeSeriesData.value.data_points[index].timestamp)
switch (selectedPeriod.value) {
case '1h':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '1day':
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })
case '7day':
return date.toLocaleDateString('en-US', { weekday: 'short' })
case '30day':
return date.toLocaleDateString('en-US', { day: 'numeric' })
default:
return `${index}`
}
}
// Fallback to index-based labels
const labels: Record<string, (i: number) => string> = {
'1h': (i) => `${i * 5}m`,
'1day': (i) => `${i}:00`,
@@ -230,20 +280,17 @@ const recentPackages = computed(() => {
.slice(0, 10)
})
// Watch for period changes and fetch new data
watch(selectedPeriod, () => {
fetchTimeSeriesData()
})
onMounted(async () => {
await store.fetchStats()
await store.fetchPackages()
await fetchTimeSeriesData()
})
function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-blue-100 text-blue-800 border-blue-200',
pypi: 'bg-green-100 text-green-800 border-green-200',
go: 'bg-yellow-100 text-yellow-800 border-yellow-200',
}
return classes[registry] || 'bg-gray-100 text-gray-800 border-gray-200'
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
+63 -67
View File
@@ -61,15 +61,27 @@
</div>
<Separator class="my-4" />
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<div class="flex flex-col gap-3">
<span class="text-gray-600">
<i class="fas fa-search mr-1"></i>
Scanned: {{ formatDate(vulnerabilities.scanned_at) }}
</span>
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanner: {{ vulnerabilities.scanner }}
</span>
<div class="flex items-center gap-2">
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanners:
</span>
<div class="flex flex-wrap gap-1">
<Badge
v-for="scanner in scannerList"
:key="scanner"
variant="secondary"
class="text-xs"
>
{{ scanner }}
</Badge>
</div>
</div>
</div>
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
<i class="fas fa-check-circle"></i>
@@ -97,8 +109,8 @@
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<div class="border-t">
<Table>
<div class="border-t overflow-x-auto">
<Table class="min-w-[800px]">
<TableHeader>
<TableRow class="bg-gray-50 hover:bg-gray-50">
<TableHead class="w-[100px]">Severity</TableHead>
@@ -131,7 +143,7 @@
<span class="font-mono text-sm font-medium">{{ vuln.id }}</span>
</TableCell>
<TableCell>
<p class="text-sm text-gray-900 line-clamp-2">{{ vuln.title || vuln.description }}</p>
<p class="text-sm text-gray-900 line-clamp-2 break-words">{{ vuln.title || vuln.description }}</p>
</TableCell>
<TableCell>
<span v-if="vuln.fixed_in" class="inline-flex items-center text-sm text-green-700">
@@ -165,7 +177,7 @@
<div>
<h5 class="font-semibold text-gray-900 mb-2">Description</h5>
<div
class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none"
class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none break-words overflow-hidden"
v-html="renderMarkdown(vuln.description)"
></div>
</div>
@@ -176,14 +188,14 @@
<i class="fas fa-info-circle"></i>
Bypass Active
</h5>
<div class="grid grid-cols-3 gap-4 text-sm text-green-800">
<div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-green-800">
<div class="break-words">
<span class="font-medium">Reason:</span> {{ vuln.bypass.reason }}
</div>
<div>
<div class="break-words">
<span class="font-medium">By:</span> {{ vuln.bypass.created_by }}
</div>
<div>
<div class="break-words">
<span class="font-medium">Expires:</span> {{ formatDate(vuln.bypass.expires_at) }}
</div>
</div>
@@ -199,20 +211,29 @@
</div>
</div>
<!-- Primary Reference -->
<div v-if="vuln.references && vuln.references.length > 0" class="flex items-center gap-2 text-sm">
<i class="fas fa-link text-gray-500"></i>
<a
:href="vuln.references[0]"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 hover:underline font-medium"
>
View Full Advisory
</a>
<span v-if="vuln.references.length > 1" class="text-gray-500">
(+{{ vuln.references.length - 1 }} more)
</span>
<!-- References -->
<div v-if="vuln.references && vuln.references.length > 0">
<h5 class="font-semibold text-gray-900 mb-2 flex items-center gap-2">
<i class="fas fa-link"></i>
References ({{ vuln.references.length }})
</h5>
<div class="space-y-1.5">
<div
v-for="(ref, refIndex) in vuln.references"
:key="refIndex"
class="flex items-start gap-2 text-sm"
>
<i class="fas fa-external-link-alt text-gray-400 text-xs mt-0.5"></i>
<a
:href="ref"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 hover:underline break-all"
>
{{ ref }}
</a>
</div>
</div>
</div>
</div>
</TableCell>
@@ -245,6 +266,12 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
getSeverityBadgeClass,
getRegistryBadgeClass,
getVulnerabilityBorderClass,
formatSeverityName,
} from '@/composables/useBadgeStyles'
// Configure marked
marked.setOptions({
@@ -328,6 +355,12 @@ const vulnerabilityList = computed(() => {
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, moderate: 0, low: 0 })
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
// Parse scanner string into array of scanner names
const scannerList = computed(() => {
if (!vulnerabilities.value?.scanner) return []
return vulnerabilities.value.scanner.split('+').map((s: string) => s.trim())
})
onMounted(() => {
fetchVulnerabilities()
})
@@ -341,11 +374,11 @@ async function fetchVulnerabilities() {
const response = await axios.get(
`/api/packages/${registry.value}/${packageName.value}/${version.value}/vulnerabilities`
)
// API wraps response in {success: true, data: {...}}
vulnerabilities.value = response.data.data
// Store the response data
vulnerabilities.value = response.data
} catch (err: any) {
console.error('Failed to fetch vulnerabilities:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to load vulnerability details'
error.value = err.response?.data?.error || err.message || 'Failed to load vulnerability details'
} finally {
loading.value = false
}
@@ -372,37 +405,6 @@ function getRowClass(index: number, severity: string): string {
return classes.join(' ')
}
function getSeverityBadgeClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white hover:bg-red-700 border-0',
HIGH: 'bg-orange-500 text-white hover:bg-orange-600 border-0',
MEDIUM: 'bg-yellow-500 text-white hover:bg-yellow-600 border-0',
LOW: 'bg-blue-500 text-white hover:bg-blue-600 border-0',
MODERATE: 'bg-yellow-500 text-white hover:bg-yellow-600 border-0',
}
return classes[severity.toUpperCase()] || 'bg-gray-500 text-white hover:bg-gray-600 border-0'
}
function getVulnerabilityBorderClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'border-l-4 border-l-red-600',
HIGH: 'border-l-4 border-l-orange-500',
MEDIUM: 'border-l-4 border-l-yellow-500',
MODERATE: 'border-l-4 border-l-yellow-500',
LOW: 'border-l-4 border-l-blue-500',
}
return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500'
}
function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-red-500 text-white border-0',
pypi: 'bg-blue-500 text-white border-0',
go: 'bg-cyan-500 text-white border-0',
}
return classes[registry] || 'bg-gray-500 text-white border-0'
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
@@ -411,10 +413,4 @@ function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parse(text) as string
}
function formatSeverityName(severity: string): string {
// Convert severity to title case (e.g., "CRITICAL" -> "Critical", "MODERATE" -> "Moderate")
const normalized = severity.toUpperCase()
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
}
</script>
+1 -9
View File
@@ -258,6 +258,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
// Props from router
const props = defineProps<{
@@ -382,15 +383,6 @@ async function deletePackage() {
}
}
function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-blue-100 text-blue-800 border-blue-200',
pypi: 'bg-green-100 text-green-800 border-green-200',
go: 'bg-yellow-100 text-yellow-800 border-yellow-200',
}
return classes[registry] || 'bg-gray-100 text-gray-800 border-gray-200'
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
+22 -5
View File
@@ -19,7 +19,7 @@
<!-- Overall Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-chart-bar mr-2"></i>Overall Statistics
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -48,7 +48,7 @@
<!-- Security Stats -->
<Card class="mb-8">
<CardContent class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-shield-alt mr-2"></i>Security Scanning
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -61,12 +61,19 @@
</div>
<i class="fas fa-check-circle text-5xl text-green-400"></i>
</div>
<div class="flex items-center justify-between p-6 bg-red-50 rounded-lg border border-red-200">
<div
@click="showVulnerablePackages"
class="flex items-center justify-between p-6 bg-red-50 rounded-lg border border-red-200 cursor-pointer hover:bg-red-100 transition-colors"
:class="{ 'opacity-50': (stats?.vulnerable_packages || 0) === 0 }"
>
<div>
<p class="text-3xl font-bold text-red-600">
{{ formatNumber(stats?.vulnerable_packages || 0) }}
</p>
<p class="text-sm text-gray-600 mt-1">Vulnerable Packages</p>
<p class="text-sm text-gray-600 mt-1">
Vulnerable Packages
<span v-if="(stats?.vulnerable_packages || 0) > 0" class="text-xs ml-1">(click to view)</span>
</p>
</div>
<i class="fas fa-exclamation-triangle text-5xl text-red-400"></i>
</div>
@@ -77,7 +84,7 @@
<!-- Registry Breakdown -->
<Card>
<CardContent class="p-6">
<h3 class="text-xl font-bold text-gray-900 mb-6">
<h3 class="text-xl font-semibold text-gray-900 mb-6">
<i class="fas fa-server mr-2"></i>Registry Breakdown
</h3>
<div class="space-y-4">
@@ -113,17 +120,27 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { usePackageStore } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
const store = usePackageStore()
const { stats, loading, error } = storeToRefs(store)
const router = useRouter()
onMounted(async () => {
await store.fetchStats()
})
function showVulnerablePackages() {
if ((stats.value?.vulnerable_packages || 0) === 0) {
return
}
router.push('/vulnerable-packages')
}
// Registry configuration for icons and colors
const registryConfig: Record<string, {label: string, icon: string, color: string}> = {
npm: {
@@ -0,0 +1,307 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Stats
</Button>
<div class="flex items-center gap-3">
<i class="fas fa-exclamation-triangle text-3xl text-red-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">Vulnerable Packages</h1>
<p class="text-gray-600 mt-1">
Packages with known security vulnerabilities, sorted by risk
</p>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerable packages...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Empty State -->
<Card v-else-if="sortedVulnerablePackages.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerable Packages</p>
<p class="mt-2 text-gray-600">All your packages are clean and safe to use!</p>
</CardContent>
</Card>
<!-- Vulnerable Packages List -->
<div v-else class="space-y-6">
<!-- Summary Card -->
<Card>
<CardContent class="p-6">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ criticalCount }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ highCount }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ moderateCount }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ lowCount }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
</CardContent>
</Card>
<!-- Packages List -->
<Card>
<CardContent class="p-6">
<div class="mb-4">
<h3 class="text-xl font-semibold text-gray-900">
<i class="fas fa-list mr-2"></i>
Vulnerable Packages ({{ sortedVulnerablePackages.length }})
</h3>
<p class="text-sm text-gray-600 mt-1">
{{ groupedPackages.length }} unique package{{ groupedPackages.length !== 1 ? 's' : '' }} Sorted by risk: Critical High Moderate Low
</p>
</div>
<Accordion type="multiple" class="w-full">
<AccordionItem
v-for="group in groupedPackages"
:key="`${group.registry}:${group.name}`"
:value="`${group.registry}:${group.name}`"
class="border-b border-gray-200"
>
<AccordionTrigger class="px-4 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center space-x-4 flex-1 min-w-0">
<div class="text-left flex-1 min-w-0">
<h4 class="font-semibold text-gray-900 break-words">{{ group.name }}</h4>
<p class="text-sm text-gray-500">{{ group.versions.length }} vulnerable version{{ group.versions.length > 1 ? 's' : '' }}</p>
</div>
</div>
<div class="flex items-center space-x-6 flex-shrink-0">
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
{{ group.registry }}
</Badge>
<div class="text-right whitespace-nowrap">
<p class="text-sm font-medium text-gray-900">{{ formatBytes(group.totalSize) }}</p>
<p class="text-xs text-gray-500">{{ formatNumber(group.totalDownloads) }} downloads</p>
</div>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<div
v-for="version in group.versions"
:key="version.id"
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
@click="navigateToPackage(version)"
>
<div class="flex items-center space-x-4 flex-1">
<div class="flex-1">
<p class="font-medium text-gray-900">{{ version.version.startsWith('v') ? version.version : 'v' + version.version }}</p>
<div class="flex items-center space-x-4 mt-1 text-sm text-gray-500">
<span>
<i class="fas fa-download mr-1"></i>{{ formatNumber(version.download_count) }}
</span>
<span>
<i class="fas fa-hard-drive mr-1"></i>{{ formatBytes(version.size) }}
</span>
<span>
<i class="fas fa-clock mr-1"></i>{{ formatDate(version.cached_at) }}
</span>
</div>
<!-- Vulnerability Badge -->
<div v-if="version.vulnerabilities" class="mt-2">
<VulnerabilityBadge
:scanned="version.vulnerabilities.scanned"
:status="version.vulnerabilities.status"
:counts="version.vulnerabilities.counts"
:total="version.vulnerabilities.total"
:scannedAt="version.vulnerabilities.scannedAt"
@click.stop="navigateToPackage(version)"
/>
</div>
</div>
</div>
<div class="ml-4 flex-shrink-0">
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import { getRegistryBadgeClass } from '@/composables/useBadgeStyles'
const store = usePackageStore()
const router = useRouter()
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerablePackages = ref<Package[]>([])
onMounted(async () => {
await fetchVulnerablePackages()
})
async function fetchVulnerablePackages() {
loading.value = true
error.value = null
try {
await store.fetchPackages()
vulnerablePackages.value = store.packages.filter(
pkg => pkg.vulnerabilities?.status === 'vulnerable'
)
} catch (err: any) {
console.error('Failed to load vulnerable packages:', err)
error.value = err.message || 'Failed to load vulnerable packages'
} finally {
loading.value = false
}
}
// Sort packages by risk: Critical count DESC, High count DESC, Moderate count DESC, Low count DESC
const sortedVulnerablePackages = computed(() => {
return [...vulnerablePackages.value].sort((a, b) => {
const aVulns = a.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
const bVulns = b.vulnerabilities?.counts || { critical: 0, high: 0, moderate: 0, low: 0 }
// Compare critical count (descending)
if (aVulns.critical !== bVulns.critical) {
return bVulns.critical - aVulns.critical
}
// Compare high count (descending)
if (aVulns.high !== bVulns.high) {
return bVulns.high - aVulns.high
}
// Compare moderate count (descending)
if (aVulns.moderate !== bVulns.moderate) {
return bVulns.moderate - aVulns.moderate
}
// Compare low count (descending)
return bVulns.low - aVulns.low
})
})
// Group packages by name and registry, with versions sorted by risk
const groupedPackages = computed(() => {
const groups = new Map<string, {
registry: string
name: string
versions: Package[]
totalSize: number
totalDownloads: number
}>()
sortedVulnerablePackages.value.forEach((pkg) => {
const key = `${pkg.registry}:${pkg.name}`
if (!groups.has(key)) {
groups.set(key, {
registry: pkg.registry,
name: pkg.name,
versions: [],
totalSize: 0,
totalDownloads: 0,
})
}
const group = groups.get(key)!
group.versions.push(pkg)
group.totalSize += pkg.size || 0
group.totalDownloads += pkg.download_count || 0
})
return Array.from(groups.values())
})
// Calculate total counts across all packages
const criticalCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.critical || 0), 0)
)
const highCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.high || 0), 0)
)
const moderateCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.moderate || 0), 0)
)
const lowCount = computed(() =>
vulnerablePackages.value.reduce((sum, pkg) => sum + (pkg.vulnerabilities?.counts?.low || 0), 0)
)
function navigateToPackage(pkg: Package) {
router.push(`/package/${pkg.registry}/${pkg.name}/${pkg.version}`)
}
function goBack() {
router.push('/stats')
}
function formatNumber(num: number): string {
return new Intl.NumberFormat().format(num)
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
}
function formatDate(dateString: string): string {
if (!dateString) return 'N/A'
try {
const date = new Date(dateString)
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return dateString
}
}
</script>
@@ -0,0 +1,59 @@
/**
* Shared badge styling utilities for consistent UI across the application
*/
/**
* Get Tailwind CSS classes for severity badges (light theme)
* @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW)
* @returns Tailwind CSS class string
*/
export function getSeverityBadgeClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'bg-red-100 text-red-800 border-red-300',
HIGH: 'bg-orange-100 text-orange-800 border-orange-300',
MEDIUM: 'bg-yellow-100 text-yellow-800 border-yellow-300',
MODERATE: 'bg-yellow-100 text-yellow-800 border-yellow-300',
LOW: 'bg-blue-100 text-blue-800 border-blue-300',
}
return classes[severity.toUpperCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
}
/**
* Get Tailwind CSS classes for registry badges (light theme)
* @param registry - Registry name (npm, pypi, go)
* @returns Tailwind CSS class string
*/
export function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-red-100 text-red-800 border-red-300',
pypi: 'bg-blue-100 text-blue-800 border-blue-300',
go: 'bg-cyan-100 text-cyan-800 border-cyan-300',
}
return classes[registry.toLowerCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
}
/**
* Get Tailwind CSS classes for vulnerability border indicators
* @param severity - Severity level (CRITICAL, HIGH, MODERATE/MEDIUM, LOW)
* @returns Tailwind CSS class string for left border
*/
export function getVulnerabilityBorderClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'border-l-4 border-l-red-600',
HIGH: 'border-l-4 border-l-orange-500',
MEDIUM: 'border-l-4 border-l-yellow-500',
MODERATE: 'border-l-4 border-l-yellow-500',
LOW: 'border-l-4 border-l-blue-500',
}
return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500'
}
/**
* Format severity name for display (title case)
* @param severity - Severity level (e.g., "CRITICAL", "HIGH")
* @returns Formatted severity name (e.g., "Critical", "High")
*/
export function formatSeverityName(severity: string): string {
const normalized = severity.toUpperCase()
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
}
+6
View File
@@ -3,6 +3,7 @@ import Dashboard from '../components/Dashboard.vue'
import PackageList from '../components/PackageList.vue'
import PackageDetails from '../components/PackageDetails.vue'
import Stats from '../components/Stats.vue'
import VulnerablePackages from '../components/VulnerablePackages.vue'
import BypassManagementPanel from '../components/BypassManagementPanel.vue'
const router = createRouter({
@@ -31,6 +32,11 @@ const router = createRouter({
name: 'stats',
component: Stats,
},
{
path: '/vulnerable-packages',
name: 'vulnerable-packages',
component: VulnerablePackages,
},
{
path: '/admin/bypasses',
name: 'bypasses',
+5 -5
View File
@@ -51,8 +51,8 @@ export const usePackageStore = defineStore('packages', () => {
try {
const response = await axios.get('/api/packages')
// Only update packages if we got valid data
if (response.data && response.data.data && Array.isArray(response.data.data.packages)) {
packages.value = response.data.data.packages
if (response.data && Array.isArray(response.data.packages)) {
packages.value = response.data.packages
} else {
console.warn('Unexpected API response format:', response.data)
error.value = 'Unexpected response format from server'
@@ -73,9 +73,9 @@ export const usePackageStore = defineStore('packages', () => {
const url = registry ? `/api/stats?registry=${registry}` : '/api/stats'
const response = await axios.get(url)
// Only update stats if we got valid data
if (response.data && response.data.data && response.data.data.stats) {
stats.value = response.data.data.stats
registries.value = response.data.data.registries || {}
if (response.data && response.data.stats) {
stats.value = response.data.stats
registries.value = response.data.registries || {}
} else {
console.warn('Unexpected stats response format:', response.data)
error.value = 'Unexpected stats response format from server'
+11 -2
View File
@@ -2,6 +2,10 @@ import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// Get backend URL from environment or use default
const BACKEND_URL = process.env.VITE_BACKEND_URL || 'http://localhost:8080'
const FRONTEND_PORT = parseInt(process.env.VITE_PORT || '5173')
export default defineConfig({
plugins: [vue()],
resolve: {
@@ -10,10 +14,15 @@ export default defineConfig({
},
},
server: {
port: 3000,
port: FRONTEND_PORT,
proxy: {
'/api': {
target: 'http://localhost:8080',
target: BACKEND_URL,
changeOrigin: true,
},
'/ws': {
target: BACKEND_URL.replace('http', 'ws'),
ws: true,
changeOrigin: true,
},
},