mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-10 23:29:22 +00:00
fixes
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user