feat: Add Project and Image detail pages with vulnerability summaries and counts

This commit is contained in:
JSC
2025-07-11 15:10:46 +02:00
parent d9cd3adef1
commit 427b776269
5 changed files with 816 additions and 15 deletions

View File

@@ -3,7 +3,9 @@ import { Toaster } from 'sonner'
import { Layout } from './components/Layout'
import { Dashboard } from './pages/Dashboard'
import { Projects } from './pages/Projects'
import { ProjectDetail } from './pages/ProjectDetail'
import { Images } from './pages/Images'
import { ImageDetail } from './pages/ImageDetail'
import { Vulnerabilities } from './pages/Vulnerabilities'
import { IgnoreRules } from './pages/IgnoreRules'
import { ScanJobs } from './pages/ScanJobs'
@@ -17,7 +19,9 @@ function App() {
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:id" element={<ProjectDetail />} />
<Route path="/images" element={<Images />} />
<Route path="/images/:id" element={<ImageDetail />} />
<Route path="/vulnerabilities" element={<Vulnerabilities />} />
<Route path="/ignore-rules" element={<IgnoreRules />} />
<Route path="/scan-jobs" element={<ScanJobs />} />

View File

@@ -10,6 +10,14 @@ export interface Project {
is_active: boolean
created_at: string
updated_at: string
vulnerability_counts?: {
critical: number
high: number
medium: number
low: number
unspecified: number
total: number
}
}
export interface Image {
@@ -99,11 +107,17 @@ class ApiClient {
}
// Projects
async getProjects(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Project[]> {
async getProjects(params?: {
skip?: number;
limit?: number;
active_only?: boolean;
include_vulnerability_counts?: boolean;
}): Promise<Project[]> {
const searchParams = new URLSearchParams()
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
if (params?.active_only !== undefined) searchParams.append('active_only', params.active_only.toString())
if (params?.include_vulnerability_counts !== undefined) searchParams.append('include_vulnerability_counts', params.include_vulnerability_counts.toString())
return this.request<Project[]>(`/projects?${searchParams}`)
}
@@ -112,6 +126,14 @@ class ApiClient {
return this.request<Project>(`/projects/${id}`)
}
async getProjectImages(id: number, params?: { skip?: number; limit?: number }): Promise<Image[]> {
const searchParams = new URLSearchParams()
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
if (params?.limit !== undefined) searchParams.append('limit', params.limit.toString())
return this.request<Image[]>(`/projects/${id}/images?${searchParams}`)
}
// Images
async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Image[]> {
const searchParams = new URLSearchParams()

334
src/pages/ImageDetail.tsx Normal file
View File

@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Image, Vulnerability } from '@/lib/api'
import {
ArrowLeft,
Container,
Shield,
AlertTriangle,
RefreshCw,
Calendar,
ExternalLink
} from 'lucide-react'
const severityOrder = {
critical: 0,
high: 1,
medium: 2,
low: 3,
unspecified: 4
}
export function ImageDetail() {
const { id } = useParams<{ id: string }>()
const [image, setImage] = useState<Image | null>(null)
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchImageData = async () => {
if (!id) return
try {
setIsLoading(true)
setError(null)
// Fetch image details and vulnerabilities in parallel
const [imageData, vulnerabilitiesData] = await Promise.all([
apiClient.getImage(parseInt(id)),
apiClient.getImageVulnerabilities(parseInt(id))
])
setImage(imageData)
// Sort vulnerabilities by severity
const sortedVulnerabilities = vulnerabilitiesData.sort((a, b) => {
const severityA = severityOrder[a.severity as keyof typeof severityOrder] ?? 5
const severityB = severityOrder[b.severity as keyof typeof severityOrder] ?? 5
return severityA - severityB
})
setVulnerabilities(sortedVulnerabilities)
} catch (error) {
console.error('Failed to fetch image data:', error)
setError('Failed to load image information')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchImageData()
}, [id])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
if (error || !image) {
return (
<div className="text-center text-red-600">
<p>{error || 'Image not found'}</p>
<Link to="/images">
<Button variant="outline" className="mt-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Images
</Button>
</Link>
</div>
)
}
const vulnerabilityCounts = vulnerabilities.reduce((acc, vuln) => {
acc[vuln.severity] = (acc[vuln.severity] || 0) + 1
return acc
}, {} as Record<string, number>)
const formatCVSSScore = (score: string | null): string => {
if (!score) return 'N/A'
const numScore = parseFloat(score)
if (isNaN(numScore)) return score
return numScore.toFixed(1)
}
const getCVSSColor = (score: string | null): string => {
if (!score) return 'text-gray-500'
const numScore = parseFloat(score)
if (isNaN(numScore)) return 'text-gray-500'
if (numScore >= 9.0) return 'text-red-600 font-bold'
if (numScore >= 7.0) return 'text-orange-600 font-semibold'
if (numScore >= 4.0) return 'text-yellow-600'
if (numScore > 0) return 'text-blue-600'
return 'text-gray-500'
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/images">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Images
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{image.image_name}</h1>
<p className="text-gray-600">{image.full_image_name}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Badge variant={image.is_active ? "default" : "secondary"}>
{image.is_active ? "Active" : "Inactive"}
</Badge>
</div>
</div>
{/* Image Information */}
<div className="grid gap-6 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Tag</CardTitle>
<Container className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{image.tag || 'latest'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Registry</CardTitle>
<ExternalLink className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{image.registry || 'Docker Hub'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Usage Count</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{image.usage_count || 0}</div>
<p className="text-xs text-muted-foreground">
Files using this image
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Seen</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm">
{new Date(image.last_seen).toLocaleString()}
</div>
</CardContent>
</Card>
</div>
{/* Vulnerability Summary */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="h-5 w-5 mr-2" />
Vulnerability Summary
</CardTitle>
<CardDescription>
Security vulnerabilities found in this Docker image
</CardDescription>
</CardHeader>
<CardContent>
{vulnerabilities.length > 0 ? (
<div className="grid gap-4 md:grid-cols-5">
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{vulnerabilityCounts.critical || 0}
</div>
<Badge variant="critical" className="text-xs">Critical</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{vulnerabilityCounts.high || 0}
</div>
<Badge variant="high" className="text-xs">High</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">
{vulnerabilityCounts.medium || 0}
</div>
<Badge variant="medium" className="text-xs">Medium</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{vulnerabilityCounts.low || 0}
</div>
<Badge variant="low" className="text-xs">Low</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">
{vulnerabilityCounts.unspecified || 0}
</div>
<Badge variant="secondary" className="text-xs">Unspecified</Badge>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
No vulnerabilities found
</div>
)}
</CardContent>
</Card>
{/* Vulnerabilities List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<AlertTriangle className="h-5 w-5 mr-2" />
Vulnerability Details
</CardTitle>
<CardDescription>
Detailed list of all vulnerabilities found in this image
</CardDescription>
</CardHeader>
<CardContent>
{vulnerabilities.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>CVE ID</TableHead>
<TableHead>Severity</TableHead>
<TableHead>CVSS Score</TableHead>
<TableHead>Title</TableHead>
<TableHead>Fixed Version</TableHead>
<TableHead>Published</TableHead>
<TableHead>Scan Job</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vulnerabilities.map((vulnerability) => (
<TableRow key={vulnerability.id}>
<TableCell>
<code className="text-sm font-mono bg-gray-100 px-2 py-1 rounded">
{vulnerability.vulnerability_id}
</code>
</TableCell>
<TableCell>
<Badge
variant={vulnerability.severity as "critical" | "high" | "medium" | "low" | "default"}
>
{vulnerability.severity.charAt(0).toUpperCase() + vulnerability.severity.slice(1)}
</Badge>
</TableCell>
<TableCell>
<span className={getCVSSColor(vulnerability.cvss_score)}>
{formatCVSSScore(vulnerability.cvss_score)}
</span>
</TableCell>
<TableCell>
<div className="max-w-md">
<div className="font-medium truncate" title={vulnerability.title || undefined}>
{vulnerability.title || 'No title available'}
</div>
{vulnerability.description && (
<div className="text-sm text-gray-600 truncate" title={vulnerability.description}>
{vulnerability.description}
</div>
)}
</div>
</TableCell>
<TableCell>
{vulnerability.fixed_version ? (
<Badge variant="outline">{vulnerability.fixed_version}</Badge>
) : (
<span className="text-gray-400">Not available</span>
)}
</TableCell>
<TableCell className="text-sm text-gray-600">
{vulnerability.published_date
? new Date(vulnerability.published_date).toLocaleDateString()
: 'Unknown'
}
</TableCell>
<TableCell>
{vulnerability.scan_job_id ? (
<Link
to={`/scan-jobs`}
className="text-blue-600 hover:underline text-sm"
>
#{vulnerability.scan_job_id}
</Link>
) : (
<span className="text-gray-400 text-sm">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-gray-500">
No vulnerabilities found for this image
</div>
)}
</CardContent>
</Card>
</div>
)
}

385
src/pages/ProjectDetail.tsx Normal file
View File

@@ -0,0 +1,385 @@
import { useEffect, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Project, Image } from '@/lib/api'
import {
ArrowLeft,
ExternalLink,
Container,
Shield,
AlertTriangle,
RefreshCw,
Calendar,
GitBranch
} from 'lucide-react'
interface ImageWithVulnerabilities extends Image {
vulnerability_counts: {
critical: number
high: number
medium: number
low: number
unspecified: number
total: number
}
}
interface ProjectVulnerabilitySummary {
critical: number
high: number
medium: number
low: number
unspecified: number
total: number
last_scan_date: string | null
}
const severityColors = {
critical: 'bg-red-100 text-red-800 border-red-200',
high: 'bg-orange-100 text-orange-800 border-orange-200',
medium: 'bg-yellow-100 text-yellow-800 border-yellow-200',
low: 'bg-blue-100 text-blue-800 border-blue-200',
unspecified: 'bg-gray-100 text-gray-800 border-gray-200',
}
export function ProjectDetail() {
const { id } = useParams<{ id: string }>()
const [project, setProject] = useState<Project | null>(null)
const [images, setImages] = useState<ImageWithVulnerabilities[]>([])
const [vulnerabilitySummary, setVulnerabilitySummary] = useState<ProjectVulnerabilitySummary | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const fetchProjectData = async () => {
if (!id) return
try {
setIsLoading(true)
setError(null)
// Fetch project details
const projectData = await apiClient.getProject(parseInt(id))
setProject(projectData)
// Fetch project images
const imagesData = await apiClient.getProjectImages(parseInt(id))
// Fetch vulnerability counts for each image
const imagesWithVulnerabilities: ImageWithVulnerabilities[] = []
let totalCritical = 0, totalHigh = 0, totalMedium = 0, totalLow = 0, totalUnspecified = 0
for (const image of imagesData) {
try {
const vulnerabilities = await apiClient.getImageVulnerabilities(image.id)
const counts = {
critical: vulnerabilities.filter(v => v.severity === 'critical').length,
high: vulnerabilities.filter(v => v.severity === 'high').length,
medium: vulnerabilities.filter(v => v.severity === 'medium').length,
low: vulnerabilities.filter(v => v.severity === 'low').length,
unspecified: vulnerabilities.filter(v => v.severity === 'unspecified').length,
total: vulnerabilities.length
}
// Add to project totals
totalCritical += counts.critical
totalHigh += counts.high
totalMedium += counts.medium
totalLow += counts.low
totalUnspecified += counts.unspecified
imagesWithVulnerabilities.push({
...image,
vulnerability_counts: counts
})
} catch (error) {
console.error(`Failed to fetch vulnerabilities for image ${image.id}:`, error)
// Add image with zero counts if vulnerability fetch fails
imagesWithVulnerabilities.push({
...image,
vulnerability_counts: {
critical: 0,
high: 0,
medium: 0,
low: 0,
unspecified: 0,
total: 0
}
})
}
}
setImages(imagesWithVulnerabilities)
// Set vulnerability summary
setVulnerabilitySummary({
critical: totalCritical,
high: totalHigh,
medium: totalMedium,
low: totalLow,
unspecified: totalUnspecified,
total: totalCritical + totalHigh + totalMedium + totalLow + totalUnspecified,
last_scan_date: projectData.last_scanned
})
} catch (error) {
console.error('Failed to fetch project data:', error)
setError('Failed to load project information')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchProjectData()
}, [id])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
if (error || !project) {
return (
<div className="text-center text-red-600">
<p>{error || 'Project not found'}</p>
<Link to="/projects">
<Button variant="outline" className="mt-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Projects
</Button>
</Link>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/projects">
<Button variant="outline" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Projects
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
<p className="text-gray-600">{project.path}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<a
href={project.web_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
<Button variant="outline">
<ExternalLink className="h-4 w-4 mr-2" />
View in GitLab
</Button>
</a>
</div>
</div>
{/* Project Information */}
<div className="grid gap-6 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Project Status</CardTitle>
<GitBranch className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Status:</span>
<Badge variant={project.is_active ? "default" : "secondary"}>
{project.is_active ? "Active" : "Inactive"}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">GitLab ID:</span>
<span className="text-sm font-mono">#{project.gitlab_id}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Scan</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-sm">
{project.last_scanned
? new Date(project.last_scanned).toLocaleString()
: 'Never scanned'
}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Docker Images</CardTitle>
<Container className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{images.length}</div>
<p className="text-xs text-muted-foreground">
Active images found
</p>
</CardContent>
</Card>
</div>
{/* Vulnerability Summary */}
{vulnerabilitySummary && (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="h-5 w-5 mr-2" />
Vulnerability Summary
</CardTitle>
<CardDescription>
Security vulnerabilities found across all images in this project
</CardDescription>
</CardHeader>
<CardContent>
{vulnerabilitySummary.total > 0 ? (
<div className="grid gap-4 md:grid-cols-5">
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{vulnerabilitySummary.critical}</div>
<Badge variant="critical" className="text-xs">Critical</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">{vulnerabilitySummary.high}</div>
<Badge variant="high" className="text-xs">High</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{vulnerabilitySummary.medium}</div>
<Badge variant="medium" className="text-xs">Medium</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{vulnerabilitySummary.low}</div>
<Badge variant="low" className="text-xs">Low</Badge>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-600">{vulnerabilitySummary.unspecified}</div>
<Badge variant="secondary" className="text-xs">Unspecified</Badge>
</div>
</div>
) : (
<div className="text-center py-8 text-gray-500">
No vulnerabilities found or project not yet scanned
</div>
)}
</CardContent>
</Card>
)}
{/* Images List */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Container className="h-5 w-5 mr-2" />
Docker Images
</CardTitle>
<CardDescription>
Images found in this project with vulnerability counts
</CardDescription>
</CardHeader>
<CardContent>
{images.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Image</TableHead>
<TableHead>Tag</TableHead>
<TableHead>Registry</TableHead>
<TableHead>Usage Count</TableHead>
<TableHead>Critical</TableHead>
<TableHead>High</TableHead>
<TableHead>Medium</TableHead>
<TableHead>Low</TableHead>
<TableHead>Total</TableHead>
<TableHead>Last Seen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{images.map((image) => (
<TableRow key={image.id}>
<TableCell>
<Link
to={`/images/${image.id}`}
className="font-medium text-blue-600 hover:underline"
>
{image.image_name}
</Link>
</TableCell>
<TableCell>
<Badge variant="outline">{image.tag || 'latest'}</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">
{image.registry || 'Docker Hub'}
</TableCell>
<TableCell>
<Badge variant="secondary">{image.usage_count || 0}</Badge>
</TableCell>
<TableCell>
{image.vulnerability_counts.critical > 0 ? (
<Badge variant="critical">{image.vulnerability_counts.critical}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{image.vulnerability_counts.high > 0 ? (
<Badge variant="high">{image.vulnerability_counts.high}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{image.vulnerability_counts.medium > 0 ? (
<Badge variant="medium">{image.vulnerability_counts.medium}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{image.vulnerability_counts.low > 0 ? (
<Badge variant="low">{image.vulnerability_counts.low}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
<Badge variant="outline">{image.vulnerability_counts.total}</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(image.last_seen).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center py-8 text-gray-500">
No Docker images found in this project
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,9 +1,11 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Project } from '@/lib/api'
import { ExternalLink, RefreshCw } from 'lucide-react'
import { ExternalLink, RefreshCw, Eye } from 'lucide-react'
export function Projects() {
const [projects, setProjects] = useState<Project[]>([])
@@ -11,7 +13,7 @@ export function Projects() {
const fetchProjects = async () => {
try {
const data = await apiClient.getProjects()
const data = await apiClient.getProjects({ include_vulnerability_counts: true })
setProjects(data)
} catch (error) {
console.error('Failed to fetch projects:', error)
@@ -37,7 +39,7 @@ export function Projects() {
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-gray-600">
GitLab projects being monitored for Docker images
GitLab projects being monitored for Docker images with vulnerability counts from latest scans
</p>
</div>
@@ -45,7 +47,7 @@ export function Projects() {
<CardHeader>
<CardTitle>Project List</CardTitle>
<CardDescription>
All projects discovered in your GitLab instance
All projects discovered in your GitLab instance with vulnerability summaries
</CardDescription>
</CardHeader>
<CardContent>
@@ -56,6 +58,11 @@ export function Projects() {
<TableHead>Path</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Scanned</TableHead>
<TableHead>Critical</TableHead>
<TableHead>High</TableHead>
<TableHead>Medium</TableHead>
<TableHead>Low</TableHead>
<TableHead>Total</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
@@ -63,7 +70,12 @@ export function Projects() {
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">
{project.name}
<Link
to={`/projects/${project.id}`}
className="text-blue-600 hover:text-blue-800 hover:underline"
>
{project.name}
</Link>
</TableCell>
<TableCell>
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
@@ -82,15 +94,59 @@ export function Projects() {
}
</TableCell>
<TableCell>
<a
href={project.web_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 hover:text-blue-800"
>
<ExternalLink className="h-4 w-4 mr-1" />
View
</a>
{project.vulnerability_counts?.critical ? (
<Badge variant="critical">{project.vulnerability_counts.critical}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{project.vulnerability_counts?.high ? (
<Badge variant="high">{project.vulnerability_counts.high}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{project.vulnerability_counts?.medium ? (
<Badge variant="medium">{project.vulnerability_counts.medium}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{project.vulnerability_counts?.low ? (
<Badge variant="low">{project.vulnerability_counts.low}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
{project.vulnerability_counts?.total ? (
<Badge variant="outline">{project.vulnerability_counts.total}</Badge>
) : (
<span className="text-gray-400">0</span>
)}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Link to={`/projects/${project.id}`}>
<Button variant="outline" size="sm">
<Eye className="h-4 w-4 mr-1" />
Details
</Button>
</Link>
<a
href={project.web_url}
target="_blank"
rel="noopener noreferrer"
>
<Button variant="outline" size="sm">
<ExternalLink className="h-4 w-4 mr-1" />
GitLab
</Button>
</a>
</div>
</TableCell>
</TableRow>
))}