feat: Add Project and Image detail pages with vulnerability summaries and counts
This commit is contained in:
@@ -3,7 +3,9 @@ import { Toaster } from 'sonner'
|
|||||||
import { Layout } from './components/Layout'
|
import { Layout } from './components/Layout'
|
||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import { Projects } from './pages/Projects'
|
import { Projects } from './pages/Projects'
|
||||||
|
import { ProjectDetail } from './pages/ProjectDetail'
|
||||||
import { Images } from './pages/Images'
|
import { Images } from './pages/Images'
|
||||||
|
import { ImageDetail } from './pages/ImageDetail'
|
||||||
import { Vulnerabilities } from './pages/Vulnerabilities'
|
import { Vulnerabilities } from './pages/Vulnerabilities'
|
||||||
import { IgnoreRules } from './pages/IgnoreRules'
|
import { IgnoreRules } from './pages/IgnoreRules'
|
||||||
import { ScanJobs } from './pages/ScanJobs'
|
import { ScanJobs } from './pages/ScanJobs'
|
||||||
@@ -17,7 +19,9 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/projects" element={<Projects />} />
|
<Route path="/projects" element={<Projects />} />
|
||||||
|
<Route path="/projects/:id" element={<ProjectDetail />} />
|
||||||
<Route path="/images" element={<Images />} />
|
<Route path="/images" element={<Images />} />
|
||||||
|
<Route path="/images/:id" element={<ImageDetail />} />
|
||||||
<Route path="/vulnerabilities" element={<Vulnerabilities />} />
|
<Route path="/vulnerabilities" element={<Vulnerabilities />} />
|
||||||
<Route path="/ignore-rules" element={<IgnoreRules />} />
|
<Route path="/ignore-rules" element={<IgnoreRules />} />
|
||||||
<Route path="/scan-jobs" element={<ScanJobs />} />
|
<Route path="/scan-jobs" element={<ScanJobs />} />
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ export interface Project {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
vulnerability_counts?: {
|
||||||
|
critical: number
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
unspecified: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Image {
|
export interface Image {
|
||||||
@@ -99,11 +107,17 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Projects
|
// 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()
|
const searchParams = new URLSearchParams()
|
||||||
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
if (params?.skip !== undefined) searchParams.append('skip', params.skip.toString())
|
||||||
if (params?.limit !== undefined) searchParams.append('limit', params.limit.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?.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}`)
|
return this.request<Project[]>(`/projects?${searchParams}`)
|
||||||
}
|
}
|
||||||
@@ -112,6 +126,14 @@ class ApiClient {
|
|||||||
return this.request<Project>(`/projects/${id}`)
|
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
|
// Images
|
||||||
async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Image[]> {
|
async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): Promise<Image[]> {
|
||||||
const searchParams = new URLSearchParams()
|
const searchParams = new URLSearchParams()
|
||||||
|
|||||||
334
src/pages/ImageDetail.tsx
Normal file
334
src/pages/ImageDetail.tsx
Normal 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
385
src/pages/ProjectDetail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import { apiClient, Project } from '@/lib/api'
|
import { apiClient, Project } from '@/lib/api'
|
||||||
import { ExternalLink, RefreshCw } from 'lucide-react'
|
import { ExternalLink, RefreshCw, Eye } from 'lucide-react'
|
||||||
|
|
||||||
export function Projects() {
|
export function Projects() {
|
||||||
const [projects, setProjects] = useState<Project[]>([])
|
const [projects, setProjects] = useState<Project[]>([])
|
||||||
@@ -11,7 +13,7 @@ export function Projects() {
|
|||||||
|
|
||||||
const fetchProjects = async () => {
|
const fetchProjects = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await apiClient.getProjects()
|
const data = await apiClient.getProjects({ include_vulnerability_counts: true })
|
||||||
setProjects(data)
|
setProjects(data)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch projects:', error)
|
console.error('Failed to fetch projects:', error)
|
||||||
@@ -37,7 +39,7 @@ export function Projects() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
|
||||||
<p className="text-gray-600">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -45,7 +47,7 @@ export function Projects() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Project List</CardTitle>
|
<CardTitle>Project List</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
All projects discovered in your GitLab instance
|
All projects discovered in your GitLab instance with vulnerability summaries
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -56,6 +58,11 @@ export function Projects() {
|
|||||||
<TableHead>Path</TableHead>
|
<TableHead>Path</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
<TableHead>Last Scanned</TableHead>
|
<TableHead>Last Scanned</TableHead>
|
||||||
|
<TableHead>Critical</TableHead>
|
||||||
|
<TableHead>High</TableHead>
|
||||||
|
<TableHead>Medium</TableHead>
|
||||||
|
<TableHead>Low</TableHead>
|
||||||
|
<TableHead>Total</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -63,7 +70,12 @@ export function Projects() {
|
|||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
<TableRow key={project.id}>
|
<TableRow key={project.id}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
to={`/projects/${project.id}`}
|
||||||
|
className="text-blue-600 hover:text-blue-800 hover:underline"
|
||||||
|
>
|
||||||
{project.name}
|
{project.name}
|
||||||
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
|
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||||
@@ -82,15 +94,59 @@ export function Projects() {
|
|||||||
}
|
}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
{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
|
<a
|
||||||
href={project.web_url}
|
href={project.web_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center text-blue-600 hover:text-blue-800"
|
|
||||||
>
|
>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
<ExternalLink className="h-4 w-4 mr-1" />
|
<ExternalLink className="h-4 w-4 mr-1" />
|
||||||
View
|
GitLab
|
||||||
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user