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,61 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<nav class="sticky top-0 z-50 bg-card/95 backdrop-blur-lg shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary text-primary-foreground">
|
||||
<i class="fas fa-box-open text-lg"></i>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-lg font-semibold tracking-tight text-foreground">GoHoarder</h1>
|
||||
<p class="text-xs text-muted-foreground">Package Cache Manager</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<router-link
|
||||
to="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
|
||||
active-class="text-foreground bg-accent"
|
||||
>
|
||||
<i class="fas fa-chart-pie text-sm"></i>
|
||||
<span>Dashboard</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/packages"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
|
||||
active-class="text-foreground bg-accent"
|
||||
>
|
||||
<i class="fas fa-boxes text-sm"></i>
|
||||
<span>Packages</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/stats"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
|
||||
active-class="text-foreground bg-accent"
|
||||
>
|
||||
<i class="fas fa-chart-bar text-sm"></i>
|
||||
<span>Statistics</span>
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/admin/bypasses"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium text-foreground/70 hover:text-foreground hover:bg-accent transition-colors"
|
||||
active-class="text-foreground bg-accent"
|
||||
>
|
||||
<i class="fas fa-shield-alt text-sm"></i>
|
||||
<span>Admin</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// App component
|
||||
</script>
|
||||
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900">CVE Bypass Management</h2>
|
||||
<p class="text-gray-600 mt-1">Manage temporary security bypasses for packages and CVEs</p>
|
||||
</div>
|
||||
<Button @click="showCreateModal = true" class="bg-green-600 hover:bg-green-700">
|
||||
<i class="fas fa-plus mr-2"></i>Create Bypass
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filter Section -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">Filter:</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="activeFilter = 'all'"
|
||||
:variant="activeFilter === 'all' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
@click="activeFilter = 'active'"
|
||||
:variant="activeFilter === 'active' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fas fa-check-circle mr-1"></i>Active
|
||||
</Button>
|
||||
<Button
|
||||
@click="activeFilter === 'expired'"
|
||||
:variant="activeFilter === 'expired' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fas fa-clock mr-1"></i>Expired
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">Type:</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="typeFilter = ''"
|
||||
:variant="typeFilter === '' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
@click="typeFilter = 'cve'"
|
||||
:variant="typeFilter === 'cve' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
CVE
|
||||
</Button>
|
||||
<Button
|
||||
@click="typeFilter = 'package'"
|
||||
:variant="typeFilter === 'package' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
Package
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="fetchBypasses" variant="outline" size="sm" class="sm:ml-auto">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- Success Alert -->
|
||||
<Alert v-if="successMessage" class="mb-4 bg-green-50 border-green-200">
|
||||
<i class="fas fa-check-circle mr-2 text-green-600"></i>
|
||||
<AlertDescription class="text-green-800">{{ successMessage }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- 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 bypasses...</p>
|
||||
</div>
|
||||
|
||||
<!-- Bypass List -->
|
||||
<Card v-else>
|
||||
<CardContent class="p-6">
|
||||
<div v-if="filteredBypasses.length === 0" class="text-center py-12 text-gray-500">
|
||||
<i class="fas fa-shield-alt text-6xl mb-4"></i>
|
||||
<p class="text-xl">No bypasses found</p>
|
||||
<p class="mt-2">Create a bypass to allow packages with known vulnerabilities</p>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="bypass in filteredBypasses"
|
||||
:key="bypass.id"
|
||||
class="border rounded-lg p-4 hover:bg-gray-50"
|
||||
:class="bypass.active ? 'border-gray-200' : 'border-gray-300 bg-gray-50'"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<Badge :variant="bypass.type === 'cve' ? 'default' : 'outline'">
|
||||
{{ bypass.type.toUpperCase() }}
|
||||
</Badge>
|
||||
<Badge
|
||||
:class="bypass.active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'"
|
||||
>
|
||||
{{ bypass.active ? 'ACTIVE' : 'INACTIVE' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isExpired(bypass.expires_at)"
|
||||
class="bg-red-100 text-red-800"
|
||||
>
|
||||
EXPIRED
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 class="font-semibold text-lg text-gray-900">{{ bypass.target }}</h4>
|
||||
<p class="text-sm text-gray-600 mt-1">{{ bypass.reason }}</p>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-3 text-sm text-gray-500">
|
||||
<div>
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
<strong>Created by:</strong> {{ bypass.created_by }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-calendar mr-1"></i>
|
||||
<strong>Created:</strong> {{ formatDate(bypass.created_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-clock mr-1"></i>
|
||||
<strong>Expires:</strong> {{ formatDate(bypass.expires_at) }}
|
||||
</div>
|
||||
<div v-if="bypass.applies_to">
|
||||
<i class="fas fa-box mr-1"></i>
|
||||
<strong>Applies to:</strong> {{ bypass.applies_to }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<Button
|
||||
@click="editBypass(bypass)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
title="Edit bypass"
|
||||
>
|
||||
<i class="fas fa-edit"></i>
|
||||
</Button>
|
||||
<Button
|
||||
@click="confirmDeleteBypass(bypass)"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
title="Delete bypass"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Create/Edit Bypass Modal -->
|
||||
<Dialog v-model:open="showCreateModal">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ editingBypass ? 'Edit' : 'Create' }} Bypass</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ editingBypass ? 'Update bypass settings' : 'Create a temporary bypass for a CVE or package' }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<!-- Type Selection -->
|
||||
<div v-if="!editingBypass">
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">Bypass Type</label>
|
||||
<div class="flex gap-4">
|
||||
<Button
|
||||
@click="bypassForm.type = 'cve'"
|
||||
:variant="bypassForm.type === 'cve' ? 'default' : 'outline'"
|
||||
class="flex-1"
|
||||
>
|
||||
<i class="fas fa-bug mr-2"></i>CVE Bypass
|
||||
</Button>
|
||||
<Button
|
||||
@click="bypassForm.type = 'package'"
|
||||
:variant="bypassForm.type === 'package' ? 'default' : 'outline'"
|
||||
class="flex-1"
|
||||
>
|
||||
<i class="fas fa-box mr-2"></i>Package Bypass
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Target -->
|
||||
<div v-if="!editingBypass">
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">
|
||||
{{ bypassForm.type === 'cve' ? 'CVE ID' : 'Package' }}
|
||||
</label>
|
||||
<Input
|
||||
v-model="bypassForm.target"
|
||||
:placeholder="bypassForm.type === 'cve' ? 'CVE-2021-23337' : 'npm/lodash@4.17.20'"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ bypassForm.type === 'cve' ? 'Enter the CVE ID (e.g., CVE-2021-23337)' : 'Enter package (e.g., npm/lodash@4.17.20 or npm/lodash for all versions)' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Applies To (CVE only) -->
|
||||
<div v-if="!editingBypass && bypassForm.type === 'cve'">
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">
|
||||
Applies To (Optional)
|
||||
</label>
|
||||
<Input
|
||||
v-model="bypassForm.applies_to"
|
||||
placeholder="npm/lodash@4.17.20"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Limit this CVE bypass to a specific package. Leave empty to apply to all packages with this CVE.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reason -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">Reason *</label>
|
||||
<Input
|
||||
v-model="bypassForm.reason"
|
||||
placeholder="No fix available, business critical dependency"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Explain why this bypass is needed (required for audit trail)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Created By -->
|
||||
<div v-if="!editingBypass">
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">Created By *</label>
|
||||
<Input
|
||||
v-model="bypassForm.created_by"
|
||||
placeholder="admin@example.com"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Expires In -->
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 mb-2 block">Expires In (Hours) *</label>
|
||||
<Input
|
||||
v-model.number="bypassForm.expires_in_hours"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="168"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
How many hours until this bypass expires (e.g., 168 = 7 days, 720 = 30 days)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Active (Edit only) -->
|
||||
<div v-if="editingBypass" class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="bypassForm.active"
|
||||
id="active-checkbox"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<label for="active-checkbox" class="text-sm font-medium text-gray-700">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Notify on Expiry -->
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="bypassForm.notify_on_expiry"
|
||||
id="notify-checkbox"
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
<label for="notify-checkbox" class="text-sm font-medium text-gray-700">
|
||||
Send notification when bypass expires
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="closeCreateModal" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="submitBypass" :disabled="!isFormValid">
|
||||
{{ editingBypass ? 'Update' : 'Create' }} Bypass
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog v-model:open="showDeleteModal">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this bypass for <strong>{{ bypassToDelete?.target }}</strong>?
|
||||
This action cannot be undone and the security check will be re-enabled immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button @click="showDeleteModal = false" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="deleteBypass" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
interface Bypass {
|
||||
id: string
|
||||
type: 'cve' | 'package'
|
||||
target: string
|
||||
reason: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
expires_at: string
|
||||
applies_to?: string
|
||||
notify_on_expiry: boolean
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const successMessage = ref<string | null>(null)
|
||||
const bypasses = ref<Bypass[]>([])
|
||||
const activeFilter = ref<'all' | 'active' | 'expired'>('all')
|
||||
const typeFilter = ref<'' | 'cve' | 'package'>('')
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showDeleteModal = ref(false)
|
||||
const editingBypass = ref<Bypass | null>(null)
|
||||
const bypassToDelete = ref<Bypass | null>(null)
|
||||
|
||||
const bypassForm = ref({
|
||||
type: 'cve' as 'cve' | 'package',
|
||||
target: '',
|
||||
reason: '',
|
||||
created_by: '',
|
||||
expires_in_hours: 168,
|
||||
applies_to: '',
|
||||
notify_on_expiry: false,
|
||||
active: true,
|
||||
})
|
||||
|
||||
// Get API key from localStorage or prompt user
|
||||
const apiKey = ref<string>('')
|
||||
|
||||
const filteredBypasses = computed(() => {
|
||||
let filtered = bypasses.value
|
||||
|
||||
// Filter by active/expired
|
||||
if (activeFilter.value === 'active') {
|
||||
filtered = filtered.filter(b => b.active && !isExpired(b.expires_at))
|
||||
} else if (activeFilter.value === 'expired') {
|
||||
filtered = filtered.filter(b => isExpired(b.expires_at))
|
||||
}
|
||||
|
||||
// Filter by type
|
||||
if (typeFilter.value) {
|
||||
filtered = filtered.filter(b => b.type === typeFilter.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (editingBypass.value) {
|
||||
return bypassForm.value.reason.trim() !== '' && bypassForm.value.expires_in_hours > 0
|
||||
}
|
||||
return (
|
||||
bypassForm.value.target.trim() !== '' &&
|
||||
bypassForm.value.reason.trim() !== '' &&
|
||||
bypassForm.value.created_by.trim() !== '' &&
|
||||
bypassForm.value.expires_in_hours > 0
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// Try to get API key from localStorage
|
||||
apiKey.value = localStorage.getItem('admin_api_key') || ''
|
||||
if (!apiKey.value) {
|
||||
promptForApiKey()
|
||||
} else {
|
||||
fetchBypasses()
|
||||
}
|
||||
})
|
||||
|
||||
function promptForApiKey() {
|
||||
const key = prompt('Enter your admin API key:')
|
||||
if (key) {
|
||||
apiKey.value = key
|
||||
localStorage.setItem('admin_api_key', key)
|
||||
fetchBypasses()
|
||||
} else {
|
||||
error.value = 'Admin API key required to manage bypasses'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBypasses() {
|
||||
if (!apiKey.value) {
|
||||
promptForApiKey()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (activeFilter.value === 'active') {
|
||||
params.append('active_only', 'true')
|
||||
} else if (activeFilter.value === 'expired') {
|
||||
params.append('include_expired', 'true')
|
||||
}
|
||||
if (typeFilter.value) {
|
||||
params.append('type', typeFilter.value)
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/admin/bypasses?' + params.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.value}`,
|
||||
},
|
||||
})
|
||||
bypasses.value = response.data.bypasses || []
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch bypasses:', err)
|
||||
if (err.response?.status === 401) {
|
||||
error.value = 'Invalid API key. Please check your credentials.'
|
||||
localStorage.removeItem('admin_api_key')
|
||||
apiKey.value = ''
|
||||
} else {
|
||||
error.value = err.response?.data?.error?.message || err.message || 'Failed to load bypasses'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBypass() {
|
||||
if (!isFormValid.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
if (editingBypass.value) {
|
||||
// Update existing bypass
|
||||
await axios.patch(
|
||||
`/api/admin/bypasses/${editingBypass.value.id}`,
|
||||
{
|
||||
active: bypassForm.value.active,
|
||||
reason: bypassForm.value.reason,
|
||||
expires_in_hours: bypassForm.value.expires_in_hours,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.value}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
successMessage.value = 'Bypass updated successfully'
|
||||
} else {
|
||||
// Create new bypass
|
||||
await axios.post(
|
||||
'/api/admin/bypasses',
|
||||
{
|
||||
type: bypassForm.value.type,
|
||||
target: bypassForm.value.target,
|
||||
reason: bypassForm.value.reason,
|
||||
created_by: bypassForm.value.created_by,
|
||||
expires_in_hours: bypassForm.value.expires_in_hours,
|
||||
applies_to: bypassForm.value.applies_to || undefined,
|
||||
notify_on_expiry: bypassForm.value.notify_on_expiry,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.value}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
successMessage.value = 'Bypass created successfully'
|
||||
}
|
||||
|
||||
closeCreateModal()
|
||||
await fetchBypasses()
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.value = null
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to submit bypass:', err)
|
||||
error.value = err.response?.data?.error?.message || err.message || 'Failed to save bypass'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function editBypass(bypass: Bypass) {
|
||||
editingBypass.value = bypass
|
||||
bypassForm.value = {
|
||||
type: bypass.type,
|
||||
target: bypass.target,
|
||||
reason: bypass.reason,
|
||||
created_by: bypass.created_by,
|
||||
expires_in_hours: 168, // Default extension
|
||||
applies_to: bypass.applies_to || '',
|
||||
notify_on_expiry: bypass.notify_on_expiry,
|
||||
active: bypass.active,
|
||||
}
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
showCreateModal.value = false
|
||||
editingBypass.value = null
|
||||
bypassForm.value = {
|
||||
type: 'cve',
|
||||
target: '',
|
||||
reason: '',
|
||||
created_by: '',
|
||||
expires_in_hours: 168,
|
||||
applies_to: '',
|
||||
notify_on_expiry: false,
|
||||
active: true,
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteBypass(bypass: Bypass) {
|
||||
bypassToDelete.value = bypass
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteBypass() {
|
||||
if (!bypassToDelete.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
successMessage.value = null
|
||||
|
||||
try {
|
||||
await axios.delete(`/api/admin/bypasses/${bypassToDelete.value.id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.value}`,
|
||||
},
|
||||
})
|
||||
|
||||
successMessage.value = 'Bypass deleted successfully'
|
||||
showDeleteModal.value = false
|
||||
bypassToDelete.value = null
|
||||
await fetchBypasses()
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
successMessage.value = null
|
||||
}, 5000)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete bypass:', err)
|
||||
error.value = err.response?.data?.error?.message || err.message || 'Failed to delete bypass'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isExpired(expiresAt: string): boolean {
|
||||
return new Date(expiresAt) < new Date()
|
||||
}
|
||||
|
||||
function formatDate(date: string): string {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,208 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Dashboard from './Dashboard.vue'
|
||||
import { usePackageStore } from '../stores/packages'
|
||||
|
||||
describe('Dashboard.vue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
// Mock the fetch functions to prevent actual API calls
|
||||
const store = usePackageStore()
|
||||
vi.spyOn(store, 'fetchStats').mockResolvedValue()
|
||||
vi.spyOn(store, 'fetchPackages').mockResolvedValue()
|
||||
})
|
||||
|
||||
it('renders dashboard component', () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
expect(wrapper.find('h2').text()).toBe('Dashboard')
|
||||
})
|
||||
|
||||
it('displays error message when error occurs', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.error = 'Failed to load dashboard'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to load dashboard')
|
||||
})
|
||||
|
||||
it('displays loading state when loading', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading statistics...')
|
||||
})
|
||||
|
||||
it('displays overview stats cards', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 2,
|
||||
total_size: 3072,
|
||||
total_downloads: 30,
|
||||
scanned_packages: 2,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Total Packages')
|
||||
expect(wrapper.text()).toContain('Total Size')
|
||||
expect(wrapper.text()).toContain('Total Downloads')
|
||||
})
|
||||
|
||||
it('displays total packages from stats', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 100,
|
||||
total_size: 0,
|
||||
total_downloads: 0,
|
||||
scanned_packages: 0,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('100')
|
||||
})
|
||||
|
||||
it('displays total downloads from stats', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 0,
|
||||
total_size: 0,
|
||||
total_downloads: 500,
|
||||
scanned_packages: 0,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('500')
|
||||
})
|
||||
|
||||
it('displays total size from stats', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 0,
|
||||
total_size: 1048576, // 1 MB
|
||||
total_downloads: 0,
|
||||
scanned_packages: 0,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('1 MB')
|
||||
})
|
||||
|
||||
it('displays recent packages section', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Recent Packages')
|
||||
})
|
||||
|
||||
it('shows recent packages sorted by cached_at', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'old-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
registry: 'npm',
|
||||
name: 'new-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-02T00:00:00Z',
|
||||
last_accessed: '2025-01-02T00:00:00Z',
|
||||
download_count: 5,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const recentPackagesSection = wrapper.text().split('Recent Packages')[1]
|
||||
|
||||
// new-package should appear before old-package since it's more recent
|
||||
expect(recentPackagesSection.indexOf('new-package')).toBeLessThan(
|
||||
recentPackagesSection.indexOf('old-package')
|
||||
)
|
||||
})
|
||||
|
||||
it('limits recent packages to 10 items', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = Array.from({ length: 15 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
registry: 'npm',
|
||||
name: `package${i}`,
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
||||
last_accessed: `2025-01-${String(i + 1).padStart(2, '0')}T00:00:00Z`,
|
||||
download_count: i,
|
||||
}))
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const recentSection = wrapper.text().split('Recent Packages')[1]
|
||||
// Count how many "package" strings appear (each package has the word "package" in its name)
|
||||
const packageCount = (recentSection.match(/package\d+/g) || []).length
|
||||
expect(packageCount).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('handles empty packages array', async () => {
|
||||
const wrapper = mount(Dashboard)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = []
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('0')
|
||||
expect(wrapper.text()).toContain('0 B')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8">Dashboard</h2>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- 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 statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-10">
|
||||
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Packages</p>
|
||||
<p class="text-3xl font-bold text-foreground tracking-tight">
|
||||
{{ formatNumber(stats?.total_packages || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-slate-100 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-boxes text-slate-700 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Size</p>
|
||||
<p class="text-3xl font-bold text-foreground tracking-tight">
|
||||
{{ formatBytes(stats?.total_size || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-sky-50 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-hard-drive text-sky-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Total Downloads</p>
|
||||
<p class="text-3xl font-bold text-foreground tracking-tight">
|
||||
{{ formatNumber(stats?.total_downloads || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-download text-emerald-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="border-0 shadow-lg hover:shadow-xl transition-shadow">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-muted-foreground">Scanned Packages</p>
|
||||
<p class="text-3xl font-bold text-foreground tracking-tight">
|
||||
{{ formatNumber(stats?.scanned_packages || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-violet-50 rounded-xl flex items-center justify-center">
|
||||
<i class="fas fa-shield-alt text-violet-600 text-lg"></i>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Downloads Chart -->
|
||||
<Card class="border-0 shadow-lg mb-10">
|
||||
<CardContent class="p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-semibold text-foreground">Download Activity</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
v-for="period in chartPeriods"
|
||||
:key="period.value"
|
||||
@click="selectedPeriod = period.value"
|
||||
:variant="selectedPeriod === period.value ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
{{ period.label }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-64 flex items-end justify-between gap-2">
|
||||
<div
|
||||
v-for="(value, index) in chartData"
|
||||
:key="index"
|
||||
class="flex-1 flex flex-col items-center gap-2"
|
||||
>
|
||||
<div class="w-full bg-slate-100 rounded-t-lg relative" :style="{ height: `${(value / maxChartValue) * 100}%` }">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-700 to-slate-500 rounded-t-lg"></div>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">{{ getChartLabel(index) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Recent Packages -->
|
||||
<Card><CardContent class="p-6">
|
||||
<h3 class="text-xl font-bold 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">
|
||||
<i class="fas fa-inbox text-4xl mb-4"></i>
|
||||
<p>No packages cached yet</p>
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Package
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Version
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Registry
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Size
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Downloads
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="pkg in recentPackages" :key="pkg.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{{ pkg.name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ pkg.version }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<Badge variant="outline" :class="getRegistryBadgeClass(pkg.registry)">
|
||||
{{ pkg.registry }}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatBytes(pkg.size) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatNumber(pkg.download_count) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
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'
|
||||
|
||||
const store = usePackageStore()
|
||||
const { packages, stats, loading, error } = storeToRefs(store)
|
||||
|
||||
// Chart periods and data
|
||||
const selectedPeriod = ref<string>('1day')
|
||||
const chartPeriods = [
|
||||
{ value: '1h', label: '1 Hour' },
|
||||
{ value: '1day', label: '24 Hours' },
|
||||
{ value: '7day', label: '7 Days' },
|
||||
{ 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),
|
||||
}
|
||||
return periods[selectedPeriod.value] || periods['1day']
|
||||
})
|
||||
|
||||
const maxChartValue = computed(() => Math.max(...chartData.value))
|
||||
|
||||
function getChartLabel(index: number): string {
|
||||
const labels: Record<string, (i: number) => string> = {
|
||||
'1h': (i) => `${i * 5}m`,
|
||||
'1day': (i) => `${i}:00`,
|
||||
'7day': (i) => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][i] || `Day ${i + 1}`,
|
||||
'30day': (i) => `${i + 1}`,
|
||||
}
|
||||
return labels[selectedPeriod.value]?.(index) || `${index}`
|
||||
}
|
||||
|
||||
// API returns clean, deduplicated data - just sort and limit
|
||||
const recentPackages = computed(() => {
|
||||
return packages.value
|
||||
.slice()
|
||||
.sort((a, b) => new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime())
|
||||
.slice(0, 10)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchStats()
|
||||
await store.fetchPackages()
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PackageList from './PackageList.vue'
|
||||
import { usePackageStore } from '../stores/packages'
|
||||
|
||||
describe('PackageList.vue', () => {
|
||||
beforeEach(() => {
|
||||
// Create a fresh pinia instance before each test
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('renders package list component', () => {
|
||||
const wrapper = mount(PackageList)
|
||||
expect(wrapper.find('h2').text()).toBe('Packages')
|
||||
})
|
||||
|
||||
it('displays loading state when loading', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading packages...')
|
||||
})
|
||||
|
||||
it('displays error message when error occurs', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.error = 'Failed to fetch packages'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to fetch packages')
|
||||
})
|
||||
|
||||
it('displays empty state when no packages', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = []
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('No packages cached yet')
|
||||
})
|
||||
|
||||
it('displays package accordion when packages exist', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('test-package')
|
||||
expect(wrapper.text()).toContain('1 version')
|
||||
})
|
||||
|
||||
it('calls fetchPackages on mount', () => {
|
||||
const store = usePackageStore()
|
||||
const fetchSpy = vi.spyOn(store, 'fetchPackages')
|
||||
|
||||
mount(PackageList)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('groups packages and displays version counts', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
registry: 'npm',
|
||||
name: 'test-package',
|
||||
version: '2.0.0',
|
||||
size: 2048,
|
||||
cached_at: '2025-01-02T00:00:00Z',
|
||||
last_accessed: '2025-01-02T00:00:00Z',
|
||||
download_count: 20,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('test-package')
|
||||
expect(wrapper.text()).toContain('2 versions')
|
||||
})
|
||||
|
||||
it('formats bytes correctly', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'test-package',
|
||||
version: '1.0.0',
|
||||
size: 1048576, // 1 MB
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('1 MB')
|
||||
})
|
||||
|
||||
it('applies correct registry badge classes', async () => {
|
||||
const wrapper = mount(PackageList)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.packages = [
|
||||
{
|
||||
id: '1',
|
||||
registry: 'npm',
|
||||
name: 'npm-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 10,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
registry: 'pypi',
|
||||
name: 'python-package',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 5,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
registry: 'go',
|
||||
name: 'go-module',
|
||||
version: '1.0.0',
|
||||
size: 1024,
|
||||
cached_at: '2025-01-01T00:00:00Z',
|
||||
last_accessed: '2025-01-01T00:00:00Z',
|
||||
download_count: 3,
|
||||
},
|
||||
]
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
// Verify that all registry badges are displayed
|
||||
// Packages are grouped and sorted alphabetically, so order is: go-module, npm-package, python-package
|
||||
expect(wrapper.text()).toContain('npm')
|
||||
expect(wrapper.text()).toContain('pypi')
|
||||
expect(wrapper.text()).toContain('go')
|
||||
|
||||
// Verify badge component is used with correct classes
|
||||
const html = wrapper.html()
|
||||
expect(html).toContain('bg-blue-100') // npm badge
|
||||
expect(html).toContain('bg-green-100') // pypi badge
|
||||
expect(html).toContain('bg-yellow-100') // go badge
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900">Packages</h2>
|
||||
<Button @click="store.fetchPackages()">
|
||||
<i class="fas fa-sync-alt mr-2"></i>Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Filter and Search Section -->
|
||||
<div class="mb-6 flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700">Filter by registry:</span>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
@click="selectedRegistry = 'all'"
|
||||
:variant="selectedRegistry === 'all' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
<Button
|
||||
@click="selectedRegistry = 'npm'"
|
||||
:variant="selectedRegistry === 'npm' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fab fa-npm mr-2"></i>NPM
|
||||
</Button>
|
||||
<Button
|
||||
@click="selectedRegistry = 'pypi'"
|
||||
:variant="selectedRegistry === 'pypi' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fab fa-python mr-2"></i>PyPI
|
||||
</Button>
|
||||
<Button
|
||||
@click="selectedRegistry = 'go'"
|
||||
:variant="selectedRegistry === 'go' ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fas fa-code mr-2"></i>Go
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 w-full sm:w-auto sm:ml-auto">
|
||||
<i class="fas fa-search text-gray-500"></i>
|
||||
<Input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
placeholder="Search packages..."
|
||||
class="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- 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 packages...</p>
|
||||
</div>
|
||||
|
||||
<!-- Package List -->
|
||||
<Card v-else>
|
||||
<CardContent class="p-6">
|
||||
<div v-if="packages.length === 0" class="text-center py-12 text-gray-500">
|
||||
<i class="fas fa-inbox text-6xl mb-4"></i>
|
||||
<p class="text-xl">No packages cached yet</p>
|
||||
<p class="mt-2">Packages will appear here once they are downloaded through the proxy</p>
|
||||
</div>
|
||||
<Accordion v-else 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">
|
||||
<div class="text-left">
|
||||
<h4 class="font-semibold text-gray-900">{{ group.name }}</h4>
|
||||
<p class="text-sm text-gray-500">{{ group.versions.length }} version{{ group.versions.length > 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-6">
|
||||
<Badge variant="outline" :class="getRegistryBadgeClass(group.registry)">
|
||||
{{ group.registry }}
|
||||
</Badge>
|
||||
<div class="text-right">
|
||||
<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"
|
||||
>
|
||||
<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="showVulnerabilityDetails(group.registry, group.name, version.version)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="confirmDelete(version)"
|
||||
class="text-red-600 hover:text-red-900 p-2"
|
||||
title="Delete this version"
|
||||
>
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="totalPages > 1" class="mt-6 flex items-center justify-between border-t pt-4">
|
||||
<div class="text-sm text-gray-700">
|
||||
Page {{ currentPage }} of {{ totalPages }} ({{ allGroupedPackages.length }} total packages)
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
@click="changePage(currentPage - 1)"
|
||||
:disabled="currentPage === 1"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<i class="fas fa-chevron-left mr-2"></i>Previous
|
||||
</Button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<!-- First page -->
|
||||
<Button
|
||||
v-if="currentPage > 3"
|
||||
@click="changePage(1)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<span v-if="currentPage > 4" class="px-2">...</span>
|
||||
|
||||
<!-- Page numbers around current page -->
|
||||
<Button
|
||||
v-for="page in getPageNumbers()"
|
||||
:key="page"
|
||||
@click="changePage(page)"
|
||||
:variant="page === currentPage ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
>
|
||||
{{ page }}
|
||||
</Button>
|
||||
|
||||
<span v-if="currentPage < totalPages - 3" class="px-2">...</span>
|
||||
<!-- Last page -->
|
||||
<Button
|
||||
v-if="currentPage < totalPages - 2"
|
||||
@click="changePage(totalPages)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{{ totalPages }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
@click="changePage(currentPage + 1)"
|
||||
:disabled="currentPage === totalPages"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Next<i class="fas fa-chevron-right ml-2"></i>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
<Dialog v-model:open="showDeleteModal">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete <strong>{{ packageToDelete?.name }}@{{ packageToDelete?.version }}</strong>?
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button @click="showDeleteModal = false" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button @click="deletePackage" variant="destructive">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Vulnerability Details Modal -->
|
||||
<VulnerabilityDetailsModal
|
||||
v-if="selectedPackage"
|
||||
v-model:open="showVulnerabilityModal"
|
||||
:registry="selectedPackage.registry"
|
||||
:package-name="selectedPackage.name"
|
||||
:version="selectedPackage.version"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { usePackageStore, type Package } from '../stores/packages'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
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 VulnerabilityDetailsModal from './VulnerabilityDetailsModal.vue'
|
||||
|
||||
const store = usePackageStore()
|
||||
const { packages, loading, error } = storeToRefs(store)
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const packageToDelete = ref<Package | null>(null)
|
||||
const showVulnerabilityModal = ref(false)
|
||||
const selectedPackage = ref<{ registry: string; name: string; version: string } | null>(null)
|
||||
const selectedRegistry = ref<string>('all')
|
||||
const searchTerm = ref<string>('')
|
||||
const currentPage = ref<number>(1)
|
||||
const itemsPerPage = ref<number>(10)
|
||||
|
||||
// Group packages by name
|
||||
const allGroupedPackages = computed(() => {
|
||||
const groups = new Map<string, Package[]>()
|
||||
|
||||
// Filter packages by selected registry and search term
|
||||
let filteredPackages = selectedRegistry.value === 'all'
|
||||
? packages.value
|
||||
: packages.value.filter(pkg => pkg.registry === selectedRegistry.value)
|
||||
|
||||
// Apply search filter if search term exists
|
||||
if (searchTerm.value.trim()) {
|
||||
const searchLower = searchTerm.value.toLowerCase()
|
||||
filteredPackages = filteredPackages.filter(pkg =>
|
||||
pkg.name.toLowerCase().includes(searchLower) ||
|
||||
pkg.version.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
filteredPackages.forEach(pkg => {
|
||||
const key = `${pkg.registry}:${pkg.name}`
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, [])
|
||||
}
|
||||
groups.get(key)!.push(pkg)
|
||||
})
|
||||
|
||||
// Convert to array and sort versions within each group
|
||||
return Array.from(groups.entries()).map(([key, versions]) => {
|
||||
const [registry, name] = key.split(':')
|
||||
return {
|
||||
registry,
|
||||
name,
|
||||
versions: versions.sort((a, b) =>
|
||||
new Date(b.cached_at).getTime() - new Date(a.cached_at).getTime()
|
||||
),
|
||||
totalSize: versions.reduce((sum, v) => sum + v.size, 0),
|
||||
totalDownloads: versions.reduce((sum, v) => sum + v.download_count, 0),
|
||||
}
|
||||
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||
})
|
||||
|
||||
// Pagination
|
||||
const totalPages = computed(() => Math.ceil(allGroupedPackages.value.length / itemsPerPage.value))
|
||||
|
||||
const groupedPackages = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage.value
|
||||
const end = start + itemsPerPage.value
|
||||
return allGroupedPackages.value.slice(start, end)
|
||||
})
|
||||
|
||||
function changePage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
// Scroll to top of packages list
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// Get page numbers to display around current page
|
||||
function getPageNumbers(): number[] {
|
||||
const pages: number[] = []
|
||||
const start = Math.max(1, currentPage.value - 2)
|
||||
const end = Math.min(totalPages.value, currentPage.value + 2)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Reset to page 1 when filters change
|
||||
function resetPagination() {
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
// Watch for filter/search changes and reset pagination
|
||||
watch([selectedRegistry, searchTerm], () => {
|
||||
resetPagination()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchPackages()
|
||||
})
|
||||
|
||||
function confirmDelete(pkg: Package) {
|
||||
packageToDelete.value = pkg
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deletePackage() {
|
||||
if (packageToDelete.value) {
|
||||
await store.deletePackage(
|
||||
packageToDelete.value.registry,
|
||||
packageToDelete.value.name,
|
||||
packageToDelete.value.version
|
||||
)
|
||||
showDeleteModal.value = false
|
||||
packageToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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(date: string): string {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
function showVulnerabilityDetails(registry: string, name: string, version: string) {
|
||||
selectedPackage.value = { registry, name, version }
|
||||
showVulnerabilityModal.value = true
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import Stats from './Stats.vue'
|
||||
import { usePackageStore } from '../stores/packages'
|
||||
|
||||
describe('Stats.vue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('renders stats component', () => {
|
||||
const wrapper = mount(Stats)
|
||||
expect(wrapper.find('h2').text()).toBe('Statistics')
|
||||
})
|
||||
|
||||
it('displays loading state when loading', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = true
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Loading statistics...')
|
||||
})
|
||||
|
||||
it('displays error message when error occurs', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.error = 'Failed to fetch statistics'
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Failed to fetch statistics')
|
||||
})
|
||||
|
||||
it('displays overall statistics', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 100,
|
||||
total_size: 1073741824, // 1 GB
|
||||
total_downloads: 500,
|
||||
scanned_packages: 90,
|
||||
vulnerable_packages: 5,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Overall Statistics')
|
||||
expect(wrapper.text()).toContain('100')
|
||||
expect(wrapper.text()).toContain('500')
|
||||
})
|
||||
|
||||
it('displays security scanning statistics', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 100,
|
||||
total_size: 1073741824,
|
||||
total_downloads: 500,
|
||||
scanned_packages: 90,
|
||||
vulnerable_packages: 5,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Security Scanning')
|
||||
expect(wrapper.text()).toContain('Scanned Packages')
|
||||
expect(wrapper.text()).toContain('Vulnerable Packages')
|
||||
})
|
||||
|
||||
it('displays registry breakdown', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 100,
|
||||
total_size: 1073741824,
|
||||
total_downloads: 500,
|
||||
scanned_packages: 90,
|
||||
vulnerable_packages: 5,
|
||||
}
|
||||
store.registries = {
|
||||
npm: {
|
||||
count: 50,
|
||||
size: 536870912, // 512 MB
|
||||
downloads: 300,
|
||||
},
|
||||
pypi: {
|
||||
count: 30,
|
||||
size: 322122547, // ~307 MB
|
||||
downloads: 150,
|
||||
},
|
||||
go: {
|
||||
count: 20,
|
||||
size: 214748365, // ~205 MB
|
||||
downloads: 50,
|
||||
},
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Registry Breakdown')
|
||||
expect(wrapper.text()).toContain('NPM Registry')
|
||||
expect(wrapper.text()).toContain('PyPI Registry')
|
||||
expect(wrapper.text()).toContain('Go Modules')
|
||||
expect(wrapper.text()).toContain('50 packages')
|
||||
expect(wrapper.text()).toContain('30 packages')
|
||||
expect(wrapper.text()).toContain('20 packages')
|
||||
})
|
||||
|
||||
it('formats bytes correctly in overall stats', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 100,
|
||||
total_size: 1073741824, // 1 GB
|
||||
total_downloads: 500,
|
||||
scanned_packages: 90,
|
||||
vulnerable_packages: 5,
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('1 GB')
|
||||
})
|
||||
|
||||
it('calls fetchStats on mount', () => {
|
||||
const store = usePackageStore()
|
||||
const fetchSpy = vi.spyOn(store, 'fetchStats')
|
||||
|
||||
mount(Stats)
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays correct icon colors for different registries', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 3,
|
||||
total_size: 0,
|
||||
total_downloads: 0,
|
||||
scanned_packages: 0,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
store.registries = {
|
||||
npm: { count: 1, size: 0, downloads: 0 },
|
||||
pypi: { count: 1, size: 0, downloads: 0 },
|
||||
go: { count: 1, size: 0, downloads: 0 },
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const containers = wrapper.findAll('.rounded-full')
|
||||
expect(containers[0].classes()).toContain('bg-red-100') // npm
|
||||
expect(containers[1].classes()).toContain('bg-blue-100') // pypi
|
||||
expect(containers[2].classes()).toContain('bg-cyan-100') // go
|
||||
})
|
||||
|
||||
it('handles empty registries data', async () => {
|
||||
const wrapper = mount(Stats)
|
||||
const store = usePackageStore()
|
||||
|
||||
store.loading = false
|
||||
store.stats = {
|
||||
registry: '',
|
||||
total_packages: 0,
|
||||
total_size: 0,
|
||||
total_downloads: 0,
|
||||
scanned_packages: 0,
|
||||
vulnerable_packages: 0,
|
||||
}
|
||||
store.registries = {}
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Registry Breakdown')
|
||||
// Should have no registry items
|
||||
const registryItems = wrapper.findAll('.bg-gray-50')
|
||||
expect(registryItems.length).toBe(3) // Only the overall stats cards
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8">Statistics</h2>
|
||||
|
||||
<!-- Error Alert -->
|
||||
<Alert v-if="error" variant="destructive" class="mb-4">
|
||||
<i class="fas fa-exclamation-circle mr-2"></i>
|
||||
<AlertDescription>{{ error }}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<!-- 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 statistics...</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div v-else>
|
||||
<!-- Overall Stats -->
|
||||
<Card class="mb-8">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="text-xl font-bold 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">
|
||||
<div class="text-center p-6 bg-gray-50 rounded-lg">
|
||||
<p class="text-4xl font-bold text-primary-600 mb-2">
|
||||
{{ formatNumber(stats?.total_packages || 0) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Total Packages</p>
|
||||
</div>
|
||||
<div class="text-center p-6 bg-gray-50 rounded-lg">
|
||||
<p class="text-4xl font-bold text-blue-600 mb-2">
|
||||
{{ formatBytes(stats?.total_size || 0) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Total Storage Used</p>
|
||||
</div>
|
||||
<div class="text-center p-6 bg-gray-50 rounded-lg">
|
||||
<p class="text-4xl font-bold text-green-600 mb-2">
|
||||
{{ formatNumber(stats?.total_downloads || 0) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600">Total Downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Security Stats -->
|
||||
<Card class="mb-8">
|
||||
<CardContent class="p-6">
|
||||
<h3 class="text-xl font-bold 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">
|
||||
<div class="flex items-center justify-between p-6 bg-green-50 rounded-lg border border-green-200">
|
||||
<div>
|
||||
<p class="text-3xl font-bold text-green-600">
|
||||
{{ formatNumber(stats?.scanned_packages || 0) }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 mt-1">Scanned Packages</p>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
<i class="fas fa-exclamation-triangle text-5xl text-red-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Registry Breakdown -->
|
||||
<Card>
|
||||
<CardContent class="p-6">
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-6">
|
||||
<i class="fas fa-server mr-2"></i>Registry Breakdown
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="registry in registries"
|
||||
:key="registry.name"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center"
|
||||
:class="registry.color"
|
||||
>
|
||||
<i :class="registry.icon + ' text-2xl'"></i>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold text-gray-900">{{ registry.label }}</p>
|
||||
<p class="text-sm text-gray-600">{{ registry.packages }} packages</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-gray-900">{{ registry.size }}</p>
|
||||
<p class="text-sm text-gray-600">{{ registry.downloads }} downloads</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
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)
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchStats()
|
||||
})
|
||||
|
||||
// Registry configuration for icons and colors
|
||||
const registryConfig: Record<string, {label: string, icon: string, color: string}> = {
|
||||
npm: {
|
||||
label: 'NPM Registry',
|
||||
icon: 'fab fa-npm text-red-500',
|
||||
color: 'bg-red-100'
|
||||
},
|
||||
pypi: {
|
||||
label: 'PyPI Registry',
|
||||
icon: 'fab fa-python text-blue-500',
|
||||
color: 'bg-blue-100'
|
||||
},
|
||||
go: {
|
||||
label: 'Go Modules',
|
||||
icon: 'fas fa-code text-cyan-500',
|
||||
color: 'bg-cyan-100'
|
||||
}
|
||||
}
|
||||
|
||||
const registries = computed(() => {
|
||||
const apiRegistries = store.registries || {}
|
||||
return Object.entries(apiRegistries).map(([name, data]: [string, any]) => {
|
||||
const config = registryConfig[name] || {
|
||||
label: name.toUpperCase(),
|
||||
icon: 'fas fa-box',
|
||||
color: 'bg-gray-100'
|
||||
}
|
||||
return {
|
||||
name,
|
||||
label: config.label,
|
||||
icon: config.icon,
|
||||
color: config.color,
|
||||
packages: data.count || 0,
|
||||
size: formatBytes(data.size || 0),
|
||||
downloads: data.downloads || 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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]
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Critical Vulnerabilities -->
|
||||
<button
|
||||
v-if="counts.critical > 0"
|
||||
@click="handleClick('critical')"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-red-100 text-red-800 hover:bg-red-200 border border-red-300 transition-colors cursor-pointer"
|
||||
:title="`${counts.critical} critical vulnerabilities - click for details`"
|
||||
>
|
||||
<i class="fas fa-shield-virus mr-1"></i>
|
||||
CRITICAL: {{ counts.critical }}
|
||||
</button>
|
||||
|
||||
<!-- High Vulnerabilities -->
|
||||
<button
|
||||
v-if="counts.high > 0"
|
||||
@click="handleClick('high')"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-orange-100 text-orange-800 hover:bg-orange-200 border border-orange-300 transition-colors cursor-pointer"
|
||||
:title="`${counts.high} high severity vulnerabilities - click for details`"
|
||||
>
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>
|
||||
HIGH: {{ counts.high }}
|
||||
</button>
|
||||
|
||||
<!-- Medium Vulnerabilities -->
|
||||
<button
|
||||
v-if="counts.medium > 0"
|
||||
@click="handleClick('medium')"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border border-yellow-300 transition-colors cursor-pointer"
|
||||
:title="`${counts.medium} medium severity vulnerabilities - click for details`"
|
||||
>
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>
|
||||
MEDIUM: {{ counts.medium }}
|
||||
</button>
|
||||
|
||||
<!-- Low Vulnerabilities -->
|
||||
<button
|
||||
v-if="counts.low > 0"
|
||||
@click="handleClick('low')"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 border border-blue-300 transition-colors cursor-pointer"
|
||||
:title="`${counts.low} low severity vulnerabilities - click for details`"
|
||||
>
|
||||
<i class="fas fa-info-circle mr-1"></i>
|
||||
LOW: {{ counts.low }}
|
||||
</button>
|
||||
|
||||
<!-- Clean Badge (no vulnerabilities) -->
|
||||
<span
|
||||
v-if="status === 'clean' && total === 0"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-green-100 text-green-800 border border-green-300"
|
||||
:title="scannedAt ? `No vulnerabilities found - Scanned ${formatTimestamp(scannedAt)}` : 'No vulnerabilities found'"
|
||||
>
|
||||
<i class="fas fa-check-circle mr-1"></i>
|
||||
CLEAN
|
||||
<span v-if="scannedAt" class="ml-1 text-[10px] opacity-70">
|
||||
({{ formatRelativeTime(scannedAt) }})
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Pending Badge -->
|
||||
<span
|
||||
v-if="status === 'pending'"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300"
|
||||
title="Security scan in progress"
|
||||
>
|
||||
<i class="fas fa-spinner fa-spin mr-1"></i>
|
||||
SCANNING...
|
||||
</span>
|
||||
|
||||
<!-- Not Scanned Badge -->
|
||||
<span
|
||||
v-if="status === 'not_scanned' || !scanned"
|
||||
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-gray-100 text-gray-600 border border-gray-300"
|
||||
title="Not yet scanned for vulnerabilities"
|
||||
>
|
||||
<i class="fas fa-question-circle mr-1"></i>
|
||||
NOT SCANNED
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { VulnerabilityCounts } from '../stores/packages'
|
||||
|
||||
interface Props {
|
||||
scanned?: boolean
|
||||
status?: 'clean' | 'vulnerable' | 'pending' | 'not_scanned'
|
||||
counts?: VulnerabilityCounts
|
||||
total?: number
|
||||
scannedAt?: string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scanned: false,
|
||||
status: 'not_scanned',
|
||||
total: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: [severity: string]
|
||||
}>()
|
||||
|
||||
const counts = computed(() => props.counts || { critical: 0, high: 0, medium: 0, low: 0 })
|
||||
|
||||
function handleClick(severity: string) {
|
||||
emit('click', severity)
|
||||
}
|
||||
|
||||
function formatTimestamp(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp: string): string {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMins < 1) return 'just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return formatTimestamp(timestamp).split(',')[0] // Just the date part
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<i class="fas fa-shield-virus text-red-600"></i>
|
||||
Security Vulnerabilities
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{{ packageInfo.registry }}/{{ packageInfo.name }}@{{ packageInfo.version }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- 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 vulnerability details...</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>
|
||||
|
||||
<!-- Vulnerability Details -->
|
||||
<div v-else-if="vulnerabilities" class="space-y-4">
|
||||
<!-- Summary Card -->
|
||||
<Card>
|
||||
<CardContent class="p-4">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-red-600">{{ severityCounts.critical }}</p>
|
||||
<p class="text-sm text-gray-600">Critical</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-orange-600">{{ severityCounts.high }}</p>
|
||||
<p class="text-sm text-gray-600">High</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-yellow-600">{{ severityCounts.medium }}</p>
|
||||
<p class="text-sm text-gray-600">Medium</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">{{ severityCounts.low }}</p>
|
||||
<p class="text-sm text-gray-600">Low</p>
|
||||
</div>
|
||||
</div>
|
||||
<Separator class="my-3" />
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<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>
|
||||
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>{{ bypassedCount }} bypassed</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- No Vulnerabilities -->
|
||||
<div v-if="vulnerabilityList.length === 0" class="text-center py-8 text-gray-500">
|
||||
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
|
||||
<p class="text-xl font-semibold">No Vulnerabilities Found</p>
|
||||
<p class="mt-2">This package is clean and safe to use</p>
|
||||
</div>
|
||||
|
||||
<!-- Vulnerability List -->
|
||||
<div v-else class="space-y-3">
|
||||
<Accordion type="multiple" class="w-full">
|
||||
<AccordionItem
|
||||
v-for="(vuln, index) in vulnerabilityList"
|
||||
:key="vuln.id"
|
||||
:value="`vuln-${index}`"
|
||||
:class="getVulnerabilityBorderClass(vuln.severity)"
|
||||
>
|
||||
<AccordionTrigger class="px-4 py-3 hover:bg-gray-50">
|
||||
<div class="flex items-center justify-between w-full pr-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge :class="getSeverityBadgeClass(vuln.severity)">
|
||||
{{ vuln.severity }}
|
||||
</Badge>
|
||||
<div class="text-left">
|
||||
<h4 class="font-semibold text-gray-900">{{ vuln.id }}</h4>
|
||||
<p class="text-sm text-gray-600">{{ vuln.title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="vuln.bypassed"
|
||||
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 border border-green-300"
|
||||
title="This vulnerability is bypassed"
|
||||
>
|
||||
<i class="fas fa-unlock mr-1"></i>
|
||||
BYPASSED
|
||||
</span>
|
||||
<span
|
||||
v-if="vuln.fixed_in"
|
||||
class="text-xs text-gray-500"
|
||||
:title="`Fixed in version ${vuln.fixed_in}`"
|
||||
>
|
||||
<i class="fas fa-wrench mr-1"></i>
|
||||
Fix: v{{ vuln.fixed_in }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent class="px-4 pb-4">
|
||||
<div class="space-y-3">
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<h5 class="font-medium text-gray-900 mb-1">Description</h5>
|
||||
<p class="text-sm text-gray-700">{{ vuln.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Bypass Information -->
|
||||
<div v-if="vuln.bypassed && vuln.bypass" class="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||
<h5 class="font-medium text-green-900 mb-2 flex items-center gap-2">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Bypass Information
|
||||
</h5>
|
||||
<div class="space-y-1 text-sm text-green-800">
|
||||
<p><strong>Reason:</strong> {{ vuln.bypass.reason }}</p>
|
||||
<p><strong>Created by:</strong> {{ vuln.bypass.created_by }}</p>
|
||||
<p><strong>Expires:</strong> {{ formatDate(vuln.bypass.expires_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fixed In -->
|
||||
<div v-if="vuln.fixed_in">
|
||||
<h5 class="font-medium text-gray-900 mb-1">Fix Available</h5>
|
||||
<p class="text-sm text-gray-700">
|
||||
<i class="fas fa-arrow-up text-green-600 mr-1"></i>
|
||||
Upgrade to version <strong>{{ vuln.fixed_in }}</strong> or later
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- References -->
|
||||
<div v-if="vuln.references && vuln.references.length > 0">
|
||||
<h5 class="font-medium text-gray-900 mb-1">References</h5>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="(ref, i) in vuln.references" :key="i">
|
||||
<a
|
||||
:href="ref"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<i class="fas fa-external-link-alt text-xs"></i>
|
||||
{{ ref }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button @click="isOpen = false" variant="outline">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from '@/components/ui/accordion'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
registry: string
|
||||
packageName: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.open,
|
||||
set: (value) => emit('update:open', value),
|
||||
})
|
||||
|
||||
interface BypassInfo {
|
||||
id: string
|
||||
reason: string
|
||||
created_by: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface Vulnerability {
|
||||
id: string
|
||||
severity: string
|
||||
title: string
|
||||
description: string
|
||||
references: string[]
|
||||
fixed_in: string
|
||||
bypassed: boolean
|
||||
bypass?: BypassInfo
|
||||
}
|
||||
|
||||
interface VulnerabilityResponse {
|
||||
scanned: boolean
|
||||
scanner: string
|
||||
scanned_at: string
|
||||
status: string
|
||||
vulnerabilities: Vulnerability[]
|
||||
vulnerability_count: number
|
||||
severity_counts: {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
}
|
||||
bypassed_count: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const vulnerabilities = ref<VulnerabilityResponse | null>(null)
|
||||
|
||||
const packageInfo = computed(() => ({
|
||||
registry: props.registry,
|
||||
name: props.packageName,
|
||||
version: props.version,
|
||||
}))
|
||||
|
||||
const vulnerabilityList = computed(() => vulnerabilities.value?.vulnerabilities || [])
|
||||
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, medium: 0, low: 0 })
|
||||
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
|
||||
|
||||
// Fetch vulnerabilities when modal opens
|
||||
watch(() => props.open, async (newValue) => {
|
||||
if (newValue) {
|
||||
await fetchVulnerabilities()
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchVulnerabilities() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
vulnerabilities.value = null
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/api/packages/${props.registry}/${props.packageName}/${props.version}/vulnerabilities`
|
||||
)
|
||||
// API wraps response in {success: true, data: {...}}
|
||||
vulnerabilities.value = response.data.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'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
LOW: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
}
|
||||
return classes[severity.toUpperCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
|
||||
}
|
||||
|
||||
function getVulnerabilityBorderClass(severity: string): string {
|
||||
const classes: Record<string, string> = {
|
||||
CRITICAL: 'border-l-4 border-l-red-500',
|
||||
HIGH: 'border-l-4 border-l-orange-500',
|
||||
MEDIUM: '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 formatDate(date: string): string {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
|
||||
import {
|
||||
AccordionRoot,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
|
||||
const props = defineProps<AccordionRootProps>()
|
||||
const emits = defineEmits<AccordionRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</AccordionRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionContent } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionContent
|
||||
v-bind="delegatedProps"
|
||||
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
>
|
||||
<div :class="cn('pb-4 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { AccordionItem, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionItem
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AccordionItem>
|
||||
</template>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import type { AccordionTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import {
|
||||
AccordionHeader,
|
||||
AccordionTrigger,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AccordionHeader class="flex">
|
||||
<AccordionTrigger
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<slot name="icon">
|
||||
<ChevronDown
|
||||
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
|
||||
/>
|
||||
</slot>
|
||||
</AccordionTrigger>
|
||||
</AccordionHeader>
|
||||
</template>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as Accordion } from "./Accordion.vue"
|
||||
export { default as AccordionContent } from "./AccordionContent.vue"
|
||||
export { default as AccordionItem } from "./AccordionItem.vue"
|
||||
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { AlertVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { alertVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
variant?: AlertVariants["variant"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(alertVariants({ variant }), props.class)" role="alert">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('text-sm [&_p]:leading-relaxed', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h5 :class="cn('mb-1 font-medium leading-none tracking-tight', props.class)">
|
||||
<slot />
|
||||
</h5>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<{
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn(badgeVariants({ variant }), props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex gap-1 items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2",
|
||||
"xs": "h-7 rounded px-2",
|
||||
"sm": "h-8 rounded-md px-3 text-xs",
|
||||
"lg": "h-10 rounded-md px-8",
|
||||
"icon": "h-9 w-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'rounded-xl border bg-card text-card-foreground shadow',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-sm text-muted-foreground', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex items-center p-6 pt-0', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-y-1.5 p-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
:class="
|
||||
cn('font-semibold leading-none tracking-tight', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
/>
|
||||
<DialogContent
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-sm text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="cn('flex flex-col gap-y-1.5 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-semibold leading-none tracking-tight',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input v-model="modelValue" :class="cn('flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50', props.class)">
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { SeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Separator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(defineProps<
|
||||
SeparatorProps & { class?: HTMLAttributes["class"] }
|
||||
>(), {
|
||||
orientation: "horizontal",
|
||||
decorative: true,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'shrink-0 bg-border',
|
||||
props.orientation === 'horizontal' ? 'h-px w-full' : 'w-px h-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Separator } from "./Separator.vue"
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SkeletonProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = defineProps<SkeletonProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('animate-pulse rounded-md bg-primary/10', props.class)" />
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from "./Skeleton.vue"
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '../components/Dashboard.vue'
|
||||
import PackageList from '../components/PackageList.vue'
|
||||
import Stats from '../components/Stats.vue'
|
||||
import BypassManagementPanel from '../components/BypassManagementPanel.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: Dashboard,
|
||||
},
|
||||
{
|
||||
path: '/packages',
|
||||
name: 'packages',
|
||||
component: PackageList,
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: Stats,
|
||||
},
|
||||
{
|
||||
path: '/admin/bypasses',
|
||||
name: 'bypasses',
|
||||
component: BypassManagementPanel,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,115 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
export interface VulnerabilityCounts {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
low: number
|
||||
}
|
||||
|
||||
export interface VulnerabilityInfo {
|
||||
scanned: boolean
|
||||
status: 'clean' | 'vulnerable' | 'pending' | 'not_scanned'
|
||||
counts?: VulnerabilityCounts
|
||||
total?: number
|
||||
scannedAt?: string // ISO 8601 timestamp
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: string
|
||||
registry: string
|
||||
name: string
|
||||
version: string
|
||||
size: number
|
||||
cached_at: string
|
||||
last_accessed: string
|
||||
download_count: number
|
||||
vulnerabilities?: VulnerabilityInfo
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
registry: string
|
||||
total_packages: number
|
||||
total_size: number
|
||||
total_downloads: number
|
||||
scanned_packages: number
|
||||
vulnerable_packages: number
|
||||
}
|
||||
|
||||
export const usePackageStore = defineStore('packages', () => {
|
||||
const packages = ref<Package[]>([])
|
||||
const stats = ref<Stats | null>(null)
|
||||
const registries = ref<Record<string, any>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchPackages() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
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
|
||||
} else {
|
||||
console.warn('Unexpected API response format:', response.data)
|
||||
error.value = 'Unexpected response format from server'
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch packages:', err)
|
||||
error.value = err.message
|
||||
// Don't clear packages on error - keep showing the cached data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchStats(registry = '') {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
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 || {}
|
||||
} else {
|
||||
console.warn('Unexpected stats response format:', response.data)
|
||||
error.value = 'Unexpected stats response format from server'
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch stats:', err)
|
||||
error.value = err.message
|
||||
// Don't clear stats on error - keep showing the cached data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePackage(registry: string, name: string, version: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await axios.delete(`/api/packages/${registry}/${name}/${version}`)
|
||||
await fetchPackages()
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
packages,
|
||||
stats,
|
||||
registries,
|
||||
loading,
|
||||
error,
|
||||
fetchPackages,
|
||||
fetchStats,
|
||||
deletePackage,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
@import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
/* Tailwind slate-50 for background */
|
||||
--background: 210 40% 98%;
|
||||
/* Tailwind slate-900 for foreground */
|
||||
--foreground: 222 47% 11%;
|
||||
/* White for cards */
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
/* Tailwind slate-700 for primary */
|
||||
--primary: 215 25% 27%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
/* Tailwind slate-100 for secondary */
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
/* Tailwind slate-100 for muted */
|
||||
--muted: 210 40% 96%;
|
||||
/* Tailwind slate-500 for muted-foreground */
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222 47% 11%;
|
||||
/* Tailwind red-500 for destructive */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
/* Tailwind slate-200 for border */
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
/* Tailwind slate-700 for ring */
|
||||
--ring: 215 25% 27%;
|
||||
/* Chart colors using Tailwind palette with slate base */
|
||||
--chart-1: 215 25% 27%;
|
||||
--chart-2: 200 98% 39%;
|
||||
--chart-3: 142 71% 45%;
|
||||
--chart-4: 25 95% 53%;
|
||||
--chart-5: 262 83% 58%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 11%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222 47% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 200 98% 39%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 217 33% 17%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 217 33% 17%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 200 98% 39%;
|
||||
--chart-1: 200 98% 39%;
|
||||
--chart-2: 142 71% 45%;
|
||||
--chart-3: 262 83% 58%;
|
||||
--chart-4: 25 95% 53%;
|
||||
--chart-5: 340 82% 52%;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom component styles have been replaced with shadcn-vue components */
|
||||
@@ -0,0 +1,11 @@
|
||||
import { expect, afterEach } from 'vitest'
|
||||
import { cleanup } from '@testing-library/vue'
|
||||
import * as matchers from '@testing-library/jest-dom/matchers'
|
||||
|
||||
// Extend Vitest's expect with jest-dom matchers
|
||||
expect.extend(matchers)
|
||||
|
||||
// Cleanup after each test case
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
Reference in New Issue
Block a user