334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
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>
|
|
)
|
|
} |