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 { 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 />} />
|
||||
|
||||
@@ -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
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 { 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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user