feat: Implement API client and dashboard functionality

- Added api.ts to handle API requests and define data models for Project, Image, Vulnerability, IgnoreRule, ScanJob, and DashboardStats.
- Created Dashboard component to display statistics and initiate scans for projects and vulnerabilities.
- Developed IgnoreRules component for managing ignore rules with filtering options.
- Implemented Images component to list discovered Docker images.
- Added Projects component to display monitored GitLab projects.
- Created ScanJobs component to show history and status of scanning operations.
- Developed Vulnerabilities component to report security vulnerabilities found in Docker images.
- Removed BrowserRouter from main.tsx as routing is not currently implemented.
This commit is contained in:
JSC
2025-07-10 22:57:22 +02:00
parent 8fe6ee937b
commit 181b3e2878
17 changed files with 1931 additions and 8 deletions

View File

@@ -1,8 +1,36 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { Toaster } from 'sonner'
import { Layout } from './components/Layout'
import { Dashboard } from './pages/Dashboard'
import { Projects } from './pages/Projects'
import { Images } from './pages/Images'
import { Vulnerabilities } from './pages/Vulnerabilities'
import { IgnoreRules } from './pages/IgnoreRules'
import { ScanJobs } from './pages/ScanJobs'
import { WebSocketProvider } from './contexts/WebSocketContext'
function App() {
return (
<>
<div>App</div>
</>
<WebSocketProvider>
<Router>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/projects" element={<Projects />} />
<Route path="/images" element={<Images />} />
<Route path="/vulnerabilities" element={<Vulnerabilities />} />
<Route path="/ignore-rules" element={<IgnoreRules />} />
<Route path="/scan-jobs" element={<ScanJobs />} />
</Routes>
</Layout>
</Router>
<Toaster
position="bottom-right"
richColors
closeButton
expand={true}
/>
</WebSocketProvider>
)
}

113
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { ReactNode } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { cn } from '@/lib/utils'
import { useWebSocketContext } from '@/contexts/WebSocketContext'
import { Badge } from '@/components/ui/badge'
import {
LayoutDashboard,
FolderOpen,
Container,
Shield,
Settings,
Activity,
Wifi,
WifiOff,
RefreshCw
} from 'lucide-react'
interface LayoutProps {
children: ReactNode
}
const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Projects', href: '/projects', icon: FolderOpen },
{ name: 'Images', href: '/images', icon: Container },
{ name: 'Vulnerabilities', href: '/vulnerabilities', icon: Shield },
{ name: 'Ignore Rules', href: '/ignore-rules', icon: Settings },
{ name: 'Scan Jobs', href: '/scan-jobs', icon: Activity },
]
export function Layout({ children }: LayoutProps) {
const location = useLocation()
const { isConnected, hasRunningScans } = useWebSocketContext()
return (
<div className="min-h-screen bg-gray-50">
<div className="flex h-screen">
{/* Sidebar */}
<div className="hidden md:flex md:w-64 md:flex-col md:fixed md:inset-y-0">
<div className="flex-1 flex flex-col min-h-0 bg-gray-800">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-gray-900">
<div className="flex-1">
<h1 className="text-white text-lg font-semibold">
GitLab Docker Tracker
</h1>
</div>
<div className="flex items-center space-x-2">
{isConnected ? (
<div className="flex items-center text-green-400 text-xs">
<Wifi className="h-3 w-3 mr-1" />
Connected
</div>
) : (
<div className="flex items-center text-red-400 text-xs">
<WifiOff className="h-3 w-3 mr-1" />
Disconnected
</div>
)}
{hasRunningScans && (
<Badge variant="secondary" className="text-xs">
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Scanning
</Badge>
)}
</div>
</div>
<div className="flex-1 flex flex-col overflow-y-auto">
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map((item) => {
const Icon = item.icon
const isActive = location.pathname === item.href
return (
<Link
key={item.name}
to={item.href}
className={cn(
'group flex items-center px-2 py-2 text-sm font-medium rounded-md',
isActive
? 'bg-gray-900 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white'
)}
>
<Icon
className={cn(
'mr-3 flex-shrink-0 h-5 w-5',
isActive
? 'text-gray-300'
: 'text-gray-400 group-hover:text-gray-300'
)}
/>
{item.name}
</Link>
)
})}
</nav>
</div>
</div>
</div>
{/* Main content */}
<div className="md:pl-64 flex flex-col flex-1">
<main className="flex-1 overflow-y-auto">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{children}
</div>
</div>
</main>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,40 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
critical: "border-transparent bg-red-500 text-white hover:bg-red-600",
high: "border-transparent bg-orange-500 text-white hover:bg-orange-600",
medium: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
low: "border-transparent bg-blue-500 text-white hover:bg-blue-600",
unspecified: "border-transparent bg-gray-500 text-white hover:bg-gray-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

113
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,113 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,27 @@
import { createContext, useContext, ReactNode } from 'react'
import { useWebSocket } from '@/hooks/useWebSocket'
interface WebSocketContextType {
isConnected: boolean
hasRunningScans: boolean
}
const WebSocketContext = createContext<WebSocketContextType | undefined>(undefined)
export function WebSocketProvider({ children }: { children: ReactNode }) {
const webSocketState = useWebSocket()
return (
<WebSocketContext.Provider value={webSocketState}>
{children}
</WebSocketContext.Provider>
)
}
export function useWebSocketContext() {
const context = useContext(WebSocketContext)
if (context === undefined) {
throw new Error('useWebSocketContext must be used within a WebSocketProvider')
}
return context
}

116
src/hooks/useWebSocket.ts Normal file
View File

@@ -0,0 +1,116 @@
import { useEffect, useState, useRef } from 'react'
import { io, Socket } from 'socket.io-client'
import { toast } from 'sonner'
interface ScanUpdate {
type: 'scan_started' | 'scan_completed' | 'scan_failed'
timestamp: string
data: {
job_type: string
job_id: number
message: string
status: string
}
}
export function useWebSocket() {
const [isConnected, setIsConnected] = useState(false)
const [hasRunningScans, setHasRunningScans] = useState(false)
const socketRef = useRef<Socket | null>(null)
const toastIdsRef = useRef<Map<number, string | number>>(new Map())
useEffect(() => {
// Connect to WebSocket
const socket = io('http://localhost:5000', {
transports: ['websocket', 'polling'],
})
socketRef.current = socket
socket.on('connect', () => {
console.log('Connected to WebSocket')
setIsConnected(true)
})
socket.on('disconnect', () => {
console.log('Disconnected from WebSocket')
setIsConnected(false)
})
socket.on('connected', (data) => {
console.log('WebSocket connection confirmed:', data)
})
socket.on('scan_update', (update: ScanUpdate) => {
console.log('Scan update received:', update)
handleScanUpdate(update)
})
return () => {
// Dismiss all running scan toasts when disconnecting
toastIdsRef.current.forEach((toastId) => {
toast.dismiss(toastId)
})
toastIdsRef.current.clear()
socket.disconnect()
}
}, [])
const handleScanUpdate = (update: ScanUpdate) => {
const { type, data } = update
const { job_type, job_id, message, status } = data
switch (type) {
case 'scan_started':
setHasRunningScans(true)
// Check if we already have a toast for this job (avoid duplicates on reconnect)
if (!toastIdsRef.current.has(job_id)) {
const loadingToastId = toast.loading(message, {
description: `${job_type} scan is running...`,
duration: Infinity, // Keep until scan completes
})
// Store the toast ID for this job
toastIdsRef.current.set(job_id, loadingToastId)
}
break
case 'scan_completed':
setHasRunningScans(false)
// Dismiss the loading toast
const completedToastId = toastIdsRef.current.get(job_id)
if (completedToastId) {
toast.dismiss(completedToastId)
toastIdsRef.current.delete(job_id)
}
// Show success toast
toast.success(message, {
description: `${job_type} scan finished successfully`,
duration: 5000,
})
break
case 'scan_failed':
setHasRunningScans(false)
// Dismiss the loading toast
const failedToastId = toastIdsRef.current.get(job_id)
if (failedToastId) {
toast.dismiss(failedToastId)
toastIdsRef.current.delete(job_id)
}
// Show error toast
toast.error('Scan Failed', {
description: message,
duration: 8000,
})
break
}
}
return {
isConnected,
hasRunningScans,
socket: socketRef.current,
}
}

213
src/lib/api.ts Normal file
View File

@@ -0,0 +1,213 @@
const API_BASE_URL = 'http://localhost:5000'
export interface Project {
id: number
gitlab_id: number
name: string
path: string
web_url: string
last_scanned: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface Image {
id: number
image_name: string
tag: string | null
registry: string | null
full_image_name: string
last_seen: string
is_active: boolean
created_at: string
updated_at: string
usage_count?: number
}
export interface Vulnerability {
id: number
image_id: number
vulnerability_id: string
severity: string
title: string | null
description: string | null
cvss_score: string | null
published_date: string | null
fixed_version: string | null
scan_date: string
is_active: boolean
}
export interface IgnoreRule {
id: number
project_id: number | null
ignore_type: string
target: string
reason: string | null
created_by: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface ScanJob {
id: number
job_type: string
status: string
project_id: number | null
started_at: string | null
completed_at: string | null
error_message: string | null
created_at: string
}
export interface DashboardStats {
total_projects: number
active_projects: number
total_images: number
active_images: number
total_vulnerabilities: number
critical_vulnerabilities: number
high_vulnerabilities: number
medium_vulnerabilities: number
low_vulnerabilities: number
last_scan: string | null
}
class ApiClient {
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
})
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`)
}
return response.json()
}
// Dashboard
async getDashboardStats(): Promise<DashboardStats> {
return this.request<DashboardStats>('/dashboard')
}
// Projects
async getProjects(params?: { skip?: number; limit?: number; active_only?: 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())
return this.request<Project[]>(`/projects?${searchParams}`)
}
async getProject(id: number): Promise<Project> {
return this.request<Project>(`/projects/${id}`)
}
// Images
async getImages(params?: { skip?: number; limit?: number; active_only?: boolean }): 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())
if (params?.active_only !== undefined) searchParams.append('active_only', params.active_only.toString())
return this.request<Image[]>(`/images?${searchParams}`)
}
async getImage(id: number): Promise<Image> {
return this.request<Image>(`/images/${id}`)
}
async getImageVulnerabilities(id: number, params?: { skip?: number; limit?: number }): Promise<Vulnerability[]> {
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<Vulnerability[]>(`/images/${id}/vulnerabilities?${searchParams}`)
}
// Vulnerabilities
async getVulnerabilities(params?: { skip?: number; limit?: number; severity?: string }): Promise<Vulnerability[]> {
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?.severity) searchParams.append('severity', params.severity)
return this.request<Vulnerability[]>(`/vulnerabilities?${searchParams}`)
}
// Ignore Rules
async getIgnoreRules(params?: { ignore_type?: string; project_id?: number }): Promise<IgnoreRule[]> {
const searchParams = new URLSearchParams()
if (params?.ignore_type) searchParams.append('ignore_type', params.ignore_type)
if (params?.project_id !== undefined) searchParams.append('project_id', params.project_id.toString())
return this.request<IgnoreRule[]>(`/ignore-rules?${searchParams}`)
}
async createIgnoreRule(rule: {
ignore_type: string
target: string
reason?: string
created_by?: string
project_id?: number
}): Promise<IgnoreRule> {
return this.request<IgnoreRule>('/ignore-rules', {
method: 'POST',
body: JSON.stringify(rule),
})
}
async deleteIgnoreRule(id: number): Promise<void> {
await this.request(`/ignore-rules/${id}`, {
method: 'DELETE',
})
}
// Scan Jobs
async getScanJobs(params?: { skip?: number; limit?: number }): Promise<ScanJob[]> {
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<ScanJob[]>(`/scan/jobs?${searchParams}`)
}
async scanProjects(): Promise<{ message: string; job_id: number; status: string }> {
return this.request<{ message: string; job_id: number; status: string }>('/scan/projects', {
method: 'POST',
})
}
async scanVulnerabilities(): Promise<{ message: string; job_id: number; status: string }> {
return this.request<{ message: string; job_id: number; status: string }>('/scan/vulnerabilities', {
method: 'POST',
})
}
async getScanJob(id: number): Promise<ScanJob> {
return this.request<ScanJob>(`/scan/jobs/${id}`)
}
async getScanStatus(): Promise<{
has_running_scans: boolean
running_jobs: Array<{
id: number
job_type: string
status: string
started_at: string | null
created_at: string
}>
}> {
return this.request('/scan/status')
}
}
export const apiClient = new ApiClient()

View File

@@ -1,10 +1,7 @@
import { BrowserRouter } from "react-router";
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<App />
</BrowserRouter>,
<App />
)

276
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,276 @@
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { apiClient, DashboardStats } from '@/lib/api'
import { useWebSocketContext } from '@/contexts/WebSocketContext'
import { toast } from 'sonner'
import {
FolderOpen,
Container,
Shield,
AlertTriangle,
Activity,
RefreshCw
} from 'lucide-react'
export function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isScanning, setIsScanning] = useState(false)
const { isConnected, hasRunningScans } = useWebSocketContext()
const fetchStats = async () => {
try {
const data = await apiClient.getDashboardStats()
setStats(data)
} catch (error) {
console.error('Failed to fetch dashboard stats:', error)
} finally {
setIsLoading(false)
}
}
const handleScanProjects = async () => {
setIsScanning(true)
try {
const response = await apiClient.scanProjects()
toast.success('Scan Started', {
description: `${response.message}`,
})
} catch (error: any) {
console.error('Failed to start project scan:', error)
if (error.message.includes('409')) {
toast.error('Cannot Start Scan', {
description: 'Another scan is already running',
})
} else {
toast.error('Scan Failed', {
description: 'Failed to start project scan',
})
}
} finally {
setIsScanning(false)
}
}
const handleScanVulnerabilities = async () => {
setIsScanning(true)
try {
const response = await apiClient.scanVulnerabilities()
toast.success('Scan Started', {
description: `${response.message}`,
})
} catch (error: any) {
console.error('Failed to start vulnerability scan:', error)
if (error.message.includes('409')) {
toast.error('Cannot Start Scan', {
description: 'Another scan is already running',
})
} else {
toast.error('Scan Failed', {
description: 'Failed to start vulnerability scan',
})
}
} finally {
setIsScanning(false)
}
}
useEffect(() => {
fetchStats()
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
if (!stats) {
return (
<div className="text-center text-gray-500">
Failed to load dashboard statistics
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-gray-600">
Overview of your GitLab Docker images and vulnerabilities
</p>
</div>
<div className="flex space-x-2">
<Button
onClick={handleScanProjects}
disabled={isScanning || hasRunningScans}
variant="outline"
>
{isScanning ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Activity className="h-4 w-4 mr-2" />
)}
Scan Projects
</Button>
<Button
onClick={handleScanVulnerabilities}
disabled={isScanning || hasRunningScans}
>
{isScanning ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Shield className="h-4 w-4 mr-2" />
)}
Scan Vulnerabilities
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Projects</CardTitle>
<FolderOpen className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_projects}</div>
<p className="text-xs text-muted-foreground">
{stats.active_projects} active
</p>
</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">{stats.total_images}</div>
<p className="text-xs text-muted-foreground">
{stats.active_images} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Vulnerabilities</CardTitle>
<Shield className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total_vulnerabilities}</div>
<p className="text-xs text-muted-foreground">
Total found
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Critical Issues</CardTitle>
<AlertTriangle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{stats.critical_vulnerabilities}
</div>
<p className="text-xs text-muted-foreground">
Require immediate attention
</p>
</CardContent>
</Card>
</div>
{/* Vulnerability Breakdown */}
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Vulnerability Breakdown</CardTitle>
<CardDescription>
Distribution of vulnerabilities by severity
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="critical">Critical</Badge>
<span className="text-sm">High risk vulnerabilities</span>
</div>
<span className="font-bold">{stats.critical_vulnerabilities}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="high">High</Badge>
<span className="text-sm">Significant security issues</span>
</div>
<span className="font-bold">{stats.high_vulnerabilities}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="medium">Medium</Badge>
<span className="text-sm">Moderate security concerns</span>
</div>
<span className="font-bold">{stats.medium_vulnerabilities}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Badge variant="low">Low</Badge>
<span className="text-sm">Minor security issues</span>
</div>
<span className="font-bold">{stats.low_vulnerabilities}</span>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Latest scan information
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<p className="text-sm font-medium">Last Scan</p>
<p className="text-xs text-muted-foreground">
{stats.last_scan
? new Date(stats.last_scan).toLocaleString()
: 'No scans performed yet'
}
</p>
</div>
<div>
<p className="text-sm font-medium">System Status</p>
<p className="text-xs text-green-600">
All services operational
</p>
</div>
<div>
<p className="text-sm font-medium">Next Actions</p>
<ul className="text-xs text-muted-foreground space-y-1">
<li> Review critical vulnerabilities</li>
<li> Update images with available fixes</li>
<li> Configure ignore rules for false positives</li>
</ul>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

181
src/pages/IgnoreRules.tsx Normal file
View File

@@ -0,0 +1,181 @@
import { useEffect, useState } from 'react'
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, IgnoreRule } from '@/lib/api'
import { Settings, RefreshCw, Plus, Trash2 } from 'lucide-react'
const ignoreTypeColors = {
image: 'default',
file: 'secondary',
project: 'outline',
} as const
export function IgnoreRules() {
const [ignoreRules, setIgnoreRules] = useState<IgnoreRule[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedType, setSelectedType] = useState<string>('')
const fetchIgnoreRules = async (ignoreType?: string) => {
try {
const data = await apiClient.getIgnoreRules({
ignore_type: ignoreType || undefined
})
setIgnoreRules(data)
} catch (error) {
console.error('Failed to fetch ignore rules:', error)
} finally {
setIsLoading(false)
}
}
const handleDeleteRule = async (id: number) => {
try {
await apiClient.deleteIgnoreRule(id)
await fetchIgnoreRules(selectedType)
} catch (error) {
console.error('Failed to delete ignore rule:', error)
}
}
useEffect(() => {
fetchIgnoreRules(selectedType)
}, [selectedType])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Ignore Rules</h1>
<p className="text-gray-600">
Manage rules to exclude projects, files, or images from scanning
</p>
</div>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Rule
</Button>
</div>
{/* Type Filter */}
<Card>
<CardHeader>
<CardTitle>Filter by Type</CardTitle>
</CardHeader>
<CardContent>
<div className="flex space-x-2">
<button
onClick={() => setSelectedType('')}
className={`px-3 py-1 rounded text-sm ${
selectedType === ''
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All
</button>
{Object.keys(ignoreTypeColors).map((type) => (
<button
key={type}
onClick={() => setSelectedType(type)}
className={`px-3 py-1 rounded text-sm ${
selectedType === type
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Badge variant={ignoreTypeColors[type as keyof typeof ignoreTypeColors]}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</Badge>
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Settings className="h-5 w-5 mr-2" />
Active Ignore Rules
</CardTitle>
<CardDescription>
Rules that exclude items from scanning and vulnerability checking
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Target</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Created By</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ignoreRules.map((rule) => (
<TableRow key={rule.id}>
<TableCell>
<Badge variant={ignoreTypeColors[rule.ignore_type as keyof typeof ignoreTypeColors]}>
{rule.ignore_type.charAt(0).toUpperCase() + rule.ignore_type.slice(1)}
</Badge>
</TableCell>
<TableCell>
<div className="font-mono text-sm max-w-md break-words">
{rule.target}
</div>
</TableCell>
<TableCell>
<div className="max-w-md">
{rule.reason || (
<span className="text-gray-400 italic">No reason provided</span>
)}
</div>
</TableCell>
<TableCell>
{rule.created_by || (
<span className="text-gray-400">Unknown</span>
)}
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(rule.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteRule(rule.id)}
className="text-red-600 hover:text-red-800"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{ignoreRules.length === 0 && (
<div className="text-center py-8 text-gray-500">
{selectedType
? `No ${selectedType} ignore rules found.`
: 'No ignore rules configured. Add rules to exclude items from scanning.'
}
</div>
)}
</CardContent>
</Card>
</div>
)
}

115
src/pages/Images.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Image } from '@/lib/api'
import { Container, RefreshCw } from 'lucide-react'
export function Images() {
const [images, setImages] = useState<Image[]>([])
const [isLoading, setIsLoading] = useState(true)
const fetchImages = async () => {
try {
const data = await apiClient.getImages()
setImages(data)
} catch (error) {
console.error('Failed to fetch images:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchImages()
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Docker Images</h1>
<p className="text-gray-600">
All Docker images discovered across your projects
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Container className="h-5 w-5 mr-2" />
Image Inventory
</CardTitle>
<CardDescription>
Docker images found in Dockerfiles, docker-compose files, and CI configurations
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Image</TableHead>
<TableHead>Registry</TableHead>
<TableHead>Tag</TableHead>
<TableHead>Usage</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Seen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{images.map((image) => (
<TableRow key={image.id}>
<TableCell>
<div className="font-medium">
{image.image_name}
</div>
<div className="text-xs text-gray-500 font-mono">
{image.full_image_name}
</div>
</TableCell>
<TableCell>
{image.registry ? (
<Badge variant="outline">{image.registry}</Badge>
) : (
<span className="text-gray-400">Docker Hub</span>
)}
</TableCell>
<TableCell>
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{image.tag || 'latest'}
</code>
</TableCell>
<TableCell>
<Badge variant="outline">
{image.usage_count || 0} files
</Badge>
</TableCell>
<TableCell>
<Badge variant={image.is_active ? "default" : "secondary"}>
{image.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(image.last_seen).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{images.length === 0 && (
<div className="text-center py-8 text-gray-500">
No images found. Run a project scan to discover Docker images.
</div>
)}
</CardContent>
</Card>
</div>
)
}

108
src/pages/Projects.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Project } from '@/lib/api'
import { ExternalLink, RefreshCw } from 'lucide-react'
export function Projects() {
const [projects, setProjects] = useState<Project[]>([])
const [isLoading, setIsLoading] = useState(true)
const fetchProjects = async () => {
try {
const data = await apiClient.getProjects()
setProjects(data)
} catch (error) {
console.error('Failed to fetch projects:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchProjects()
}, [])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-gray-600">
GitLab projects being monitored for Docker images
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Project List</CardTitle>
<CardDescription>
All projects discovered in your GitLab instance
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Path</TableHead>
<TableHead>Status</TableHead>
<TableHead>Last Scanned</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">
{project.name}
</TableCell>
<TableCell>
<code className="text-sm bg-gray-100 px-2 py-1 rounded">
{project.path}
</code>
</TableCell>
<TableCell>
<Badge variant={project.is_active ? "default" : "secondary"}>
{project.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell className="text-sm text-gray-600">
{project.last_scanned
? new Date(project.last_scanned).toLocaleDateString()
: 'Never'
}
</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>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{projects.length === 0 && (
<div className="text-center py-8 text-gray-500">
No projects found. Run a project scan to discover projects.
</div>
)}
</CardContent>
</Card>
</div>
)
}

189
src/pages/ScanJobs.tsx Normal file
View File

@@ -0,0 +1,189 @@
import { useEffect, useState, useRef } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, ScanJob } from '@/lib/api'
import { useWebSocketContext } from '@/contexts/WebSocketContext'
import { io, Socket } from 'socket.io-client'
import { Activity, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react'
const statusColors = {
pending: 'secondary',
running: 'default',
completed: 'outline',
failed: 'destructive',
} as const
const statusIcons = {
pending: Clock,
running: RefreshCw,
completed: CheckCircle,
failed: XCircle,
}
interface ScanUpdate {
type: 'scan_started' | 'scan_completed' | 'scan_failed'
timestamp: string
data: {
job_type: string
job_id: number
message: string
status: string
}
}
export function ScanJobs() {
const [scanJobs, setScanJobs] = useState<ScanJob[]>([])
const [isLoading, setIsLoading] = useState(true)
const socketRef = useRef<Socket | null>(null)
const fetchScanJobs = async () => {
try {
const data = await apiClient.getScanJobs({ limit: 50 })
setScanJobs(data)
} catch (error) {
console.error('Failed to fetch scan jobs:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
// Initial fetch
fetchScanJobs()
// Set up WebSocket connection to listen for scan events
const socket = io('http://localhost:5000', {
transports: ['websocket', 'polling'],
})
socketRef.current = socket
socket.on('connect', () => {
console.log('ScanJobs: Connected to WebSocket')
})
socket.on('scan_update', (update: ScanUpdate) => {
console.log('ScanJobs: Scan update received:', update)
// Refetch scan jobs when any scan event occurs
fetchScanJobs()
})
socket.on('disconnect', () => {
console.log('ScanJobs: Disconnected from WebSocket')
})
return () => {
socket.disconnect()
}
}, [])
const getDuration = (job: ScanJob): string => {
if (!job.started_at) return 'N/A'
const start = new Date(job.started_at)
const end = job.completed_at ? new Date(job.completed_at) : new Date()
const duration = Math.round((end.getTime() - start.getTime()) / 1000)
if (duration < 60) return `${duration}s`
if (duration < 3600) return `${Math.round(duration / 60)}m`
return `${Math.round(duration / 3600)}h`
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Scan Jobs</h1>
<p className="text-gray-600">
History and status of scanning operations
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Activity className="h-5 w-5 mr-2" />
Job History
</CardTitle>
<CardDescription>
Recent project discovery and vulnerability scanning jobs
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Job ID</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Started</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{scanJobs.map((job) => {
const StatusIcon = statusIcons[job.status as keyof typeof statusIcons]
return (
<TableRow key={job.id}>
<TableCell>
<div className="font-mono text-sm">#{job.id}</div>
</TableCell>
<TableCell>
<Badge variant="outline">
{job.job_type === 'discovery' ? 'Project Discovery' : 'Vulnerability Scan'}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<StatusIcon
className={`h-4 w-4 ${
job.status === 'running' ? 'animate-spin' : ''
}`}
/>
<Badge variant={statusColors[job.status as keyof typeof statusColors]}>
{job.status.charAt(0).toUpperCase() + job.status.slice(1)}
</Badge>
</div>
</TableCell>
<TableCell className="text-sm text-gray-600">
{job.started_at
? new Date(job.started_at).toLocaleString()
: 'Not started'
}
</TableCell>
<TableCell className="text-sm">
{getDuration(job)}
</TableCell>
<TableCell>
{job.error_message ? (
<div className="max-w-md text-sm text-red-600 truncate" title={job.error_message}>
{job.error_message}
</div>
) : (
<span className="text-gray-400">-</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
{scanJobs.length === 0 && (
<div className="text-center py-8 text-gray-500">
No scan jobs found. Start a scan to see job history.
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,177 @@
import { useEffect, useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { apiClient, Vulnerability } from '@/lib/api'
import { Shield, RefreshCw, AlertTriangle } from 'lucide-react'
const severityColors = {
critical: 'critical',
high: 'high',
medium: 'medium',
low: 'low',
unspecified: 'unspecified',
} as const
export function Vulnerabilities() {
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedSeverity, setSelectedSeverity] = useState<string>('')
const fetchVulnerabilities = async (severity?: string) => {
try {
const data = await apiClient.getVulnerabilities({
severity: severity || undefined,
limit: 100
})
setVulnerabilities(data)
} catch (error) {
console.error('Failed to fetch vulnerabilities:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchVulnerabilities(selectedSeverity)
}, [selectedSeverity])
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin" />
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Vulnerabilities</h1>
<p className="text-gray-600">
Security vulnerabilities discovered in Docker images
</p>
</div>
{/* Severity Filter */}
<Card>
<CardHeader>
<CardTitle>Filter by Severity</CardTitle>
</CardHeader>
<CardContent>
<div className="flex space-x-2">
<button
onClick={() => setSelectedSeverity('')}
className={`px-3 py-1 rounded text-sm ${
selectedSeverity === ''
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
All
</button>
{Object.keys(severityColors).map((severity) => (
<button
key={severity}
onClick={() => setSelectedSeverity(severity)}
className={`px-3 py-1 rounded text-sm ${
selectedSeverity === severity
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Badge variant={severityColors[severity as keyof typeof severityColors]}>
{severity.charAt(0).toUpperCase() + severity.slice(1)}
</Badge>
</button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="h-5 w-5 mr-2" />
Vulnerability Report
</CardTitle>
<CardDescription>
Security issues found in your Docker images
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Vulnerability ID</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Title</TableHead>
<TableHead>CVSS Score</TableHead>
<TableHead>Fixed Version</TableHead>
<TableHead>Scan Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vulnerabilities.map((vuln) => (
<TableRow key={vuln.id}>
<TableCell>
<div className="font-mono text-sm">{vuln.vulnerability_id}</div>
</TableCell>
<TableCell>
<Badge variant={severityColors[vuln.severity as keyof typeof severityColors]}>
{vuln.severity.charAt(0).toUpperCase() + vuln.severity.slice(1)}
</Badge>
</TableCell>
<TableCell>
<div className="max-w-md">
<div className="font-medium truncate">
{vuln.title || 'No title available'}
</div>
{vuln.description && (
<div className="text-xs text-gray-500 truncate">
{vuln.description.slice(0, 100)}...
</div>
)}
</div>
</TableCell>
<TableCell>
{vuln.cvss_score ? (
<div className="flex items-center">
<span className="font-medium">{vuln.cvss_score}</span>
{parseFloat(vuln.cvss_score) >= 7 && (
<AlertTriangle className="h-4 w-4 ml-1 text-red-500" />
)}
</div>
) : (
<span className="text-gray-400">N/A</span>
)}
</TableCell>
<TableCell>
{vuln.fixed_version ? (
<code className="text-sm bg-green-100 text-green-800 px-1 rounded">
{vuln.fixed_version}
</code>
) : (
<span className="text-gray-400">No fix available</span>
)}
</TableCell>
<TableCell className="text-sm text-gray-600">
{new Date(vuln.scan_date).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{vulnerabilities.length === 0 && (
<div className="text-center py-8 text-gray-500">
{selectedSeverity
? `No ${selectedSeverity} vulnerabilities found.`
: 'No vulnerabilities found. Run a vulnerability scan to check for security issues.'
}
</div>
)}
</CardContent>
</Card>
</div>
)
}