Fix module path and add build time display to UI

Backend changes:
- Updated go.mod module path from github.com/container-census to
  github.com/selfhosters-cc to match correct GitHub organization
- Updated all import paths across codebase to use new module name
- This fixes ldflags injection of BuildTime during compilation
- BuildTime now correctly shows in /api/health response

Frontend changes:
- Added build time badge next to version in header
- Shows date and time in compact format (e.g., "🔨 12/11/2025 8:06 PM")
- Hover shows full timestamp
- Only displays if build_time is not "unknown"

The build script already sets BuildTime via ldflags, but it was being
ignored because the module path in go.mod didn't match the ldflags path.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Self Hosters
2025-12-11 20:12:10 -05:00
parent db4d46992c
commit 2cf3b7c0d6
85 changed files with 3623 additions and 917 deletions
+1 -1
View File
@@ -1 +1 @@
1.8.4
2.0.2
+7
View File
@@ -10,6 +10,13 @@ Container Census is a multi-host Docker monitoring system written in Go. It cons
2. **Agent** (`cmd/agent`): Lightweight agent for remote Docker hosts
3. **Telemetry Collector** (`cmd/telemetry-collector`): Analytics aggregation service with PostgreSQL backend
### Frontend
**IMPORTANT**: Container Census uses a **Next.js/React** frontend (`web-next/`) as the primary UI. The vanilla JavaScript frontend (`web/`) is **deprecated** and kept only for reference - it will be removed in a future release. All new development should use the Next.js frontend.
- **Active**: `web-next/` - Next.js 16 with TypeScript and React
- **Deprecated**: `web/` - Vanilla JS (reference only, DO NOT USE for new features)
## Build Instructions
**IMPORTANT**: When building binaries during development, ALWAYS build to `/tmp/container-census`:
+13 -5
View File
@@ -23,11 +23,17 @@ COPY . .
# Tidy if needed (rarely changes cache)
RUN go mod tidy -e
# Build the binary with proper tags for Alpine
RUN CGO_ENABLED=1 GOOS=linux go build -buildvcs=false -tags "sqlite_omit_load_extension" -o census ./cmd/server
# Build the binary with proper tags for Alpine and inject build timestamp
RUN BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") && \
CGO_ENABLED=1 GOOS=linux go build \
-buildvcs=false \
-tags "sqlite_omit_load_extension" \
-ldflags "-X github.com/selfhosters-cc/container-census/internal/version.BuildTime=${BUILD_TIME}" \
-o census \
./cmd/server
# Stage 2: Create minimal runtime image
FROM alpine:latest
FROM alpine:3.21
# Build arg for docker group GID (defaults to 999, can be overridden at runtime)
ARG DOCKER_GID=999
@@ -70,8 +76,10 @@ COPY --from=builder /build/.version ./.version
# Copy changelog for "What's New" feature
COPY --from=builder /build/CHANGELOG.md ./CHANGELOG.md
# Copy web frontend
COPY --from=builder /build/web ./web
# Copy Next.js web frontend (pre-built static export)
# To build: cd web-next && npm run build
# Output is in web-next/out which gets copied here as ./web
COPY --from=builder /build/web-next/out ./web
# Copy example config
COPY --from=builder /build/config/config.yaml.example ./config/config.yaml.example
+27 -5
View File
@@ -27,13 +27,31 @@ RUN go mod tidy -e
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-w -s" -o census-agent ./cmd/agent
# Stage 2: Create minimal runtime image
FROM alpine:latest
FROM alpine:3.21
# Build arg for docker group GID (defaults to 999)
ARG DOCKER_GID=999
# Install ca-certificates for HTTPS
RUN apk --no-cache add ca-certificates tzdata
# Build arg for optional Trivy installation
ARG INSTALL_TRIVY=false
ARG TRIVY_VERSION=0.58.1
# Install ca-certificates for HTTPS and conditionally install Trivy
RUN apk --no-cache add ca-certificates tzdata && \
if [ "$INSTALL_TRIVY" = "true" ]; then \
apk --no-cache add wget && \
ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) TRIVY_ARCH="64bit" ;; \
aarch64) TRIVY_ARCH="ARM64" ;; \
armv7l) TRIVY_ARCH="ARM" ;; \
*) echo "Unsupported architecture: $ARCH" && exit 1 ;; \
esac && \
wget -qO- https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}/trivy_${TRIVY_VERSION}_Linux-${TRIVY_ARCH}.tar.gz | tar -xzf - -C /usr/local/bin trivy && \
chmod +x /usr/local/bin/trivy && \
trivy --version && \
apk del wget; \
fi
# Create docker group with host's GID and census user
RUN (getent group ${DOCKER_GID} && delgroup $(getent group ${DOCKER_GID} | cut -d: -f1)) || true && \
@@ -51,8 +69,12 @@ COPY --from=builder /build/census-agent .
# Copy version file
COPY --from=builder /build/.version ./.version
# Create data directory for token persistence
RUN mkdir -p /app/data && chown census:census /app/data
# Create data directory for token persistence and conditionally create Trivy cache directory
RUN mkdir -p /app/data && chown census:census /app/data && \
if [ "$INSTALL_TRIVY" = "true" ]; then \
mkdir -p /app/data/.trivy && \
chown census:census /app/data/.trivy; \
fi
# Switch to non-root user
USER census
+1 -1
View File
@@ -26,7 +26,7 @@ RUN go mod tidy -e
RUN CGO_ENABLED=0 GOOS=linux go build -buildvcs=false -ldflags="-w -s" -o telemetry-collector ./cmd/telemetry-collector
# Stage 2: Create minimal runtime image
FROM alpine:latest
FROM alpine:3.21
# Install ca-certificates for HTTPS and timezone data
RUN apk --no-cache add ca-certificates tzdata
+39 -7
View File
@@ -7,14 +7,16 @@ import (
"log"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"github.com/container-census/container-census/internal/agent"
"github.com/container-census/container-census/internal/version"
"github.com/selfhosters-cc/container-census/internal/agent"
"github.com/selfhosters-cc/container-census/internal/version"
)
func main() {
@@ -48,19 +50,29 @@ func main() {
// Get version
agentVersion := version.Get()
// Check Trivy availability
hasTrivyBool, trivyVer := checkTrivyAvailability()
// Create agent info
agentInfo := agent.Info{
Version: agentVersion,
Hostname: hostname,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
StartedAt: time.Now(),
Version: agentVersion,
Hostname: hostname,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
StartedAt: time.Now(),
HasTrivy: hasTrivyBool,
TrivyVersion: trivyVer,
}
log.Printf("Starting Container Census Agent v%s", agentVersion)
log.Printf("Hostname: %s", hostname)
log.Printf("OS: %s/%s", runtime.GOOS, runtime.GOARCH)
log.Printf("Docker Host: %s", *dockerHost)
if hasTrivyBool {
log.Printf("Trivy: v%s (vulnerability scanning enabled)", trivyVer)
} else {
log.Printf("Trivy: not available (vulnerability scanning disabled)")
}
// Create agent server
agentServer, err := agent.New(*dockerHost, *apiToken, agentInfo)
@@ -192,3 +204,23 @@ func loadOrGenerateToken(tokenFile string) string {
return token
}
// checkTrivyAvailability checks if Trivy is installed and returns version
func checkTrivyAvailability() (bool, string) {
cmd := exec.Command("trivy", "--version")
output, err := cmd.Output()
if err != nil {
return false, ""
}
// Parse version from output
// Expected format: "Version: 0.58.1"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "Version:") {
version := strings.TrimSpace(strings.TrimPrefix(line, "Version:"))
return true, version
}
}
return true, "unknown"
}
+19 -19
View File
@@ -13,21 +13,21 @@ import (
"syscall"
"time"
"github.com/container-census/container-census/internal/api"
"github.com/container-census/container-census/internal/auth"
"github.com/container-census/container-census/internal/migration"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications"
"github.com/container-census/container-census/internal/plugins"
"github.com/container-census/container-census/internal/plugins/builtin/graph"
"github.com/container-census/container-census/internal/plugins/builtin/npm"
"github.com/container-census/container-census/internal/plugins/builtin/security"
"github.com/container-census/container-census/internal/registry"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
"github.com/container-census/container-census/internal/telemetry"
"github.com/container-census/container-census/internal/version"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/api"
"github.com/selfhosters-cc/container-census/internal/auth"
"github.com/selfhosters-cc/container-census/internal/migration"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/notifications"
"github.com/selfhosters-cc/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/graph"
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/npm"
"github.com/selfhosters-cc/container-census/internal/plugins/builtin/security"
"github.com/selfhosters-cc/container-census/internal/registry"
"github.com/selfhosters-cc/container-census/internal/scanner"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/telemetry"
"github.com/selfhosters-cc/container-census/internal/version"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
)
// Global scan interval that can be updated dynamically
@@ -345,7 +345,7 @@ func main() {
log.Printf("Loaded vulnerability settings from database (cache_dir: %s)", vulnConfig.GetCacheDir())
vulnScanner := vulnerability.NewScanner(vulnConfig, db)
vulnScheduler := vulnerability.NewScheduler(vulnScanner, vulnConfig)
vulnScheduler := vulnerability.NewScheduler(vulnScanner, vulnConfig, db)
vulnScheduler.Start()
log.Printf("Vulnerability scanner initialized (%d workers, auto-scan: %v)", vulnConfig.GetWorkerPoolSize(), vulnConfig.GetAutoScanNewImages())
@@ -572,9 +572,9 @@ func queueImagesForScanning(containers []models.Container, hostID int64, db *sto
log.Printf("Warning: Failed to update image-container mapping: %v", err)
}
// Queue for scanning (non-blocking)
// Note: QueueScan internally checks NeedsScan() and returns nil if already scanned recently
if err := vulnerabilitySchedulerGlobal.QueueScan(container.ImageID, container.Image, 0); err != nil {
// Queue for scanning with host context (enables routing to agent)
// Note: QueueScanWithHost internally checks NeedsScan() and returns nil if already scanned recently
if err := vulnerabilitySchedulerGlobal.QueueScanWithHost(container.ImageID, container.Image, hostID, 0); err != nil {
log.Printf("Warning: Failed to queue image for scanning: %v", err)
}
}
+6 -6
View File
@@ -15,8 +15,8 @@ import (
"syscall"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/version"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/version"
"github.com/gorilla/mux"
_ "github.com/lib/pq" // PostgreSQL driver
)
@@ -28,7 +28,7 @@ type Config struct {
AuthUsername string
AuthPassword string
StatsAPIKey string // API key for stats endpoints
StatsMinInstalls int // Minimum installations for trending stats (default: 10)
StatsMinInstalls int // Minimum installations for trending stats (default: 3)
}
type Server struct {
@@ -57,7 +57,7 @@ func main() {
AuthUsername: getEnv("COLLECTOR_AUTH_USERNAME", ""),
AuthPassword: getEnv("COLLECTOR_AUTH_PASSWORD", ""),
StatsAPIKey: getEnv("STATS_API_KEY", ""),
StatsMinInstalls: getEnvInt("STATS_MIN_INSTALLATIONS", 10),
StatsMinInstalls: getEnvInt("STATS_MIN_INSTALLATIONS", 3),
}
if config.AuthEnabled {
@@ -2404,9 +2404,9 @@ func (s *Server) handleMoversStats(w http.ResponseWriter, r *http.Request) {
previousQuery := `
SELECT image, total_count, installation_count
FROM image_stats_weekly
WHERE week_start = $1
WHERE week_start = $1 AND installation_count >= $2
`
previousRows, err := s.db.Query(previousQuery, previousWeek)
previousRows, err := s.db.Query(previousQuery, previousWeek, minInstallations)
if err != nil {
respondError(w, http.StatusInternalServerError, "Query failed: "+err.Error())
return
+1 -1
View File
@@ -1,4 +1,4 @@
module github.com/container-census/container-census
module github.com/selfhosters-cc/container-census
go 1.25
+139 -1
View File
@@ -1,6 +1,7 @@
package agent
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
@@ -9,11 +10,14 @@ import (
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
@@ -29,6 +33,8 @@ type Info struct {
Arch string `json:"arch"`
DockerVersion string `json:"docker_version"`
StartedAt time.Time `json:"started_at"`
HasTrivy bool `json:"has_trivy"` // Whether Trivy is available
TrivyVersion string `json:"trivy_version"` // Trivy version if available
}
// Agent handles Docker operations on a single host
@@ -97,6 +103,11 @@ func (a *Agent) setupRoutes() {
// Telemetry endpoint
api.HandleFunc("/telemetry", a.handleGetTelemetry).Methods("GET")
// Vulnerability scanning endpoints (if Trivy available)
api.HandleFunc("/vulnerabilities/scan", a.handleScanImage).Methods("POST")
api.HandleFunc("/vulnerabilities/db-update", a.handleUpdateTrivyDB).Methods("POST")
api.HandleFunc("/vulnerabilities/cache-clear", a.handleClearTrivyCache).Methods("POST")
}
// Router returns the configured router
@@ -804,6 +815,133 @@ func createDockerClient(dockerHost string) (*client.Client, error) {
)
}
// handleScanImage scans an image using local Trivy
func (a *Agent) handleScanImage(w http.ResponseWriter, r *http.Request) {
if !a.info.HasTrivy {
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
return
}
var req struct {
ImageID string `json:"image_id"`
ImageName string `json:"image_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
if req.ImageName == "" {
respondError(w, http.StatusBadRequest, "image_name is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
result, err := a.runTrivyScan(ctx, req.ImageName)
if err != nil {
respondError(w, http.StatusInternalServerError, "Scan failed: "+err.Error())
return
}
respondJSON(w, http.StatusOK, result)
}
// runTrivyScan executes Trivy CLI and returns raw JSON
func (a *Agent) runTrivyScan(ctx context.Context, imageRef string) (map[string]interface{}, error) {
cacheDir := "/app/data/.trivy"
args := []string{
"image",
"--format", "json",
"--quiet",
"--no-progress",
"--cache-dir", cacheDir,
"--image-src", "docker",
}
// Skip DB update if DB exists (prevent lock conflicts)
dbPath := filepath.Join(cacheDir, "db", "trivy.db")
if _, err := os.Stat(dbPath); err == nil {
args = append(args, "--skip-db-update", "--skip-java-db-update")
}
args = append(args, imageRef)
cmd := exec.CommandContext(ctx, "trivy", args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
stderrStr := stderr.String()
if strings.Contains(stderrStr, "unable to find the specified image") ||
strings.Contains(stderrStr, "No such image") {
return nil, fmt.Errorf("image not available for scanning")
}
return nil, fmt.Errorf("trivy command failed: %w (stderr: %s)", err, stderrStr)
}
// Parse JSON and return as generic map
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
return nil, fmt.Errorf("failed to parse trivy output: %w", err)
}
return result, nil
}
// handleUpdateTrivyDB updates the Trivy vulnerability database
func (a *Agent) handleUpdateTrivyDB(w http.ResponseWriter, r *http.Request) {
if !a.info.HasTrivy {
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
cacheDir := "/app/data/.trivy"
cmd := exec.CommandContext(ctx, "trivy", "image", "--download-db-only", "--cache-dir", cacheDir)
output, err := cmd.CombinedOutput()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update database: "+string(output))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Trivy database updated successfully",
"output": string(output),
})
}
// handleClearTrivyCache clears the Trivy cache directory
func (a *Agent) handleClearTrivyCache(w http.ResponseWriter, r *http.Request) {
if !a.info.HasTrivy {
respondError(w, http.StatusNotImplemented, "Trivy not available on this agent")
return
}
cacheDir := "/app/data/.trivy"
if err := os.RemoveAll(cacheDir); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to clear cache: "+err.Error())
return
}
if err := os.MkdirAll(cacheDir, 0755); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to recreate cache directory: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Trivy cache cleared successfully",
})
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+91 -1
View File
@@ -8,7 +8,7 @@ import (
"strings"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/gorilla/mux"
)
@@ -30,6 +30,96 @@ func detectHostType(address string) string {
}
}
// handleAddHost adds a new host (generic handler that detects type from address)
func (s *Server) handleAddHost(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Address string `json:"address"`
Description string `json:"description"`
AgentToken string `json:"agent_token,omitempty"` // Legacy field name
APIToken string `json:"api_token,omitempty"` // Preferred field name
CollectStats bool `json:"collect_stats"`
EnableVulnerabilityScanning bool `json:"enable_vulnerability_scanning"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
// Validate required fields
if req.Name == "" {
respondError(w, http.StatusBadRequest, "Name is required")
return
}
if req.Address == "" {
respondError(w, http.StatusBadRequest, "Address is required")
return
}
// Detect host type from address
hostType := detectHostType(req.Address)
// Use api_token if provided, otherwise fall back to agent_token
token := req.APIToken
if token == "" {
token = req.AgentToken
}
// For agent hosts, require token
if hostType == "agent" && token == "" {
respondError(w, http.StatusBadRequest, "Agent token is required for agent hosts")
return
}
// Verify connectivity based on host type
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
host := models.Host{
Name: req.Name,
Address: req.Address,
Description: req.Description,
HostType: hostType,
AgentToken: token,
AgentStatus: "unknown",
Enabled: true,
CollectStats: req.CollectStats,
EnableVulnerabilityScanning: req.EnableVulnerabilityScanning,
}
// For agent hosts, verify connectivity and authentication
if hostType == "agent" {
if err := s.verifyAgentConnection(ctx, host); err != nil {
respondError(w, http.StatusBadGateway, "Failed to connect to agent: "+err.Error())
return
}
// Also verify authentication
if err := s.scanner.VerifyAgentAuth(ctx, host); err != nil {
if strings.Contains(err.Error(), "401") || strings.Contains(err.Error(), "authentication failed") {
respondError(w, http.StatusUnauthorized, "Agent authentication failed - invalid API token")
} else {
respondError(w, http.StatusBadGateway, "Failed to verify agent authentication: "+err.Error())
}
return
}
host.AgentStatus = "online"
host.LastSeen = time.Now()
}
// Add to database
id, err := s.db.AddHost(host)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to add host: "+err.Error())
return
}
host.ID = id
respondJSON(w, http.StatusCreated, host)
}
// handleAddAgentHost adds a new agent-based host
func (s *Server) handleAddAgentHost(w http.ResponseWriter, r *http.Request) {
var req struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"net/http"
"github.com/container-census/container-census/internal/auth"
"github.com/selfhosters-cc/container-census/internal/auth"
)
// LoginRequest represents the login request payload
+263 -12
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
@@ -13,15 +14,15 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/auth"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications"
"github.com/container-census/container-census/internal/plugins"
"github.com/container-census/container-census/internal/registry"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
"github.com/container-census/container-census/internal/telemetry"
"github.com/container-census/container-census/internal/version"
"github.com/selfhosters-cc/container-census/internal/auth"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/notifications"
"github.com/selfhosters-cc/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/registry"
"github.com/selfhosters-cc/container-census/internal/scanner"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/telemetry"
"github.com/selfhosters-cc/container-census/internal/version"
"github.com/google/uuid"
"github.com/gorilla/mux"
)
@@ -183,13 +184,22 @@ func (s *Server) setupRoutes() {
// Host endpoints
api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET")
api.HandleFunc("/hosts", s.handleAddHost).Methods("POST")
// Host Trivy management endpoints (must be before /hosts/{id} to avoid route conflicts)
api.HandleFunc("/hosts/trivy-summary", s.handleGetTrivySummary).Methods("GET")
api.HandleFunc("/hosts/bulk-trivy-update", s.handleBulkTrivyUpdate).Methods("POST")
api.HandleFunc("/hosts/agent", s.handleAddAgentHost).Methods("POST")
api.HandleFunc("/hosts/agent/test", s.handleTestAgentConnection).Methods("POST")
api.HandleFunc("/hosts/agent/{id}/info", s.handleGetAgentInfo).Methods("GET")
// Host CRUD endpoints (must be after specific routes)
api.HandleFunc("/hosts/{id}", s.handleGetHost).Methods("GET")
api.HandleFunc("/hosts/{id}", s.handleUpdateHost).Methods("PUT")
api.HandleFunc("/hosts/{id}", s.handleDeleteHost).Methods("DELETE")
api.HandleFunc("/hosts/{id}/scan", s.handleScanHost).Methods("POST")
api.HandleFunc("/hosts/agent", s.handleAddAgentHost).Methods("POST")
api.HandleFunc("/hosts/agent/test", s.handleTestAgentConnection).Methods("POST")
api.HandleFunc("/hosts/agent/{id}/info", s.handleGetAgentInfo).Methods("GET")
api.HandleFunc("/hosts/{id}/trivy-update", s.handleHostTrivyUpdate).Methods("POST")
api.HandleFunc("/hosts/{id}/trivy-clear-cache", s.handleHostTrivyClearCache).Methods("POST")
// Container endpoints
api.HandleFunc("/containers", s.handleGetContainers).Methods("GET")
@@ -426,6 +436,63 @@ func (s *Server) handleDeleteHost(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]string{"message": "Host deleted successfully"})
}
func (s *Server) handleGetTrivySummary(w http.ResponseWriter, r *http.Request) {
// Get all hosts
hosts, err := s.db.GetHosts()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
return
}
withTrivy := 0
withoutTrivy := 0
disabled := 0
totalAgents := len(hosts)
// Check local host
if s.vulnScanner != nil {
withTrivy++ // Local host has Trivy
}
// Check each agent host
for _, host := range hosts {
if !host.EnableVulnerabilityScanning {
disabled++
continue
}
// For agent hosts, check if they have Trivy
if host.HostType == "agent" {
// Try to get agent info
if s.scanner != nil {
if agentInfo, err := s.scanner.GetAgentInfo(r.Context(), host); err == nil && agentInfo.HasTrivy {
withTrivy++
} else {
withoutTrivy++
}
} else {
withoutTrivy++
}
} else if host.HostType == "unix" {
// Unix hosts use local Trivy
if s.vulnScanner != nil {
withTrivy++
} else {
withoutTrivy++
}
}
}
response := map[string]interface{}{
"with_trivy": withTrivy,
"without_trivy": withoutTrivy,
"disabled": disabled,
"total_agents": totalAgents,
}
respondJSON(w, http.StatusOK, response)
}
func (s *Server) handleGetContainers(w http.ResponseWriter, r *http.Request) {
containers, err := s.db.GetLatestContainers()
if err != nil {
@@ -901,6 +968,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
response := map[string]interface{}{
"status": "healthy",
"version": version.Get(),
"build_time": version.GetBuildTime(),
"time": time.Now().Format(time.RFC3339),
"auth_enabled": s.authConfig.Enabled,
}
@@ -2239,3 +2307,186 @@ func (s *Server) handlePluginAsset(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, fullPath)
}
// handleBulkTrivyUpdate updates Trivy database on all agents
func (s *Server) handleBulkTrivyUpdate(w http.ResponseWriter, r *http.Request) {
hosts, err := s.db.GetHosts()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to list hosts")
return
}
updateCount := 0
for _, host := range hosts {
if !isAgentHost(host.Address) {
continue
}
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), &host)
if err != nil || !agentInfo.HasTrivy {
continue
}
// Launch update asynchronously
go func(host models.Host) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := updateAgentTrivyDB(ctx, host); err != nil {
log.Printf("Failed to update Trivy DB on %s: %v", host.Name, err)
}
}(host)
updateCount++
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Trivy DB update initiated",
"updated": updateCount,
})
}
// handleHostTrivyUpdate updates Trivy database on a specific agent
func (s *Server) handleHostTrivyUpdate(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hostID, err := strconv.ParseInt(vars["id"], 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid host ID")
return
}
host, err := s.db.GetHost(hostID)
if err != nil {
respondError(w, http.StatusNotFound, "Host not found")
return
}
if !isAgentHost(host.Address) {
respondError(w, http.StatusBadRequest, "Host is not an agent")
return
}
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), host)
if err != nil {
respondError(w, http.StatusBadGateway, "Failed to connect to agent")
return
}
if !agentInfo.HasTrivy {
respondError(w, http.StatusBadRequest, "Agent does not have Trivy installed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
if err := updateAgentTrivyDB(ctx, *host); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update Trivy DB: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Trivy database updated successfully"})
}
// handleHostTrivyClearCache clears Trivy cache on a specific agent
func (s *Server) handleHostTrivyClearCache(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hostID, err := strconv.ParseInt(vars["id"], 10, 64)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid host ID")
return
}
host, err := s.db.GetHost(hostID)
if err != nil {
respondError(w, http.StatusNotFound, "Host not found")
return
}
if !isAgentHost(host.Address) {
respondError(w, http.StatusBadRequest, "Host is not an agent")
return
}
agentInfo, err := s.vulnScanner.GetAgentInfo(r.Context(), host)
if err != nil {
respondError(w, http.StatusBadGateway, "Failed to connect to agent")
return
}
if !agentInfo.HasTrivy {
respondError(w, http.StatusBadRequest, "Agent does not have Trivy installed")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if err := clearAgentTrivyCache(ctx, *host); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to clear cache: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Trivy cache cleared successfully"})
}
// updateAgentTrivyDB makes HTTP call to agent to update Trivy database
func updateAgentTrivyDB(ctx context.Context, host models.Host) error {
url := normalizeAgentURL(host.Address) + "/api/vulnerabilities/db-update"
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Token", host.AgentToken)
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
}
return nil
}
// clearAgentTrivyCache makes HTTP call to agent to clear Trivy cache
func clearAgentTrivyCache(ctx context.Context, host models.Host) error {
url := normalizeAgentURL(host.Address) + "/api/vulnerabilities/cache-clear"
req, err := http.NewRequestWithContext(ctx, "POST", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Token", host.AgentToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
}
return nil
}
// isAgentHost checks if a host address is agent-based
func isAgentHost(address string) bool {
return strings.HasPrefix(address, "agent://") ||
strings.HasPrefix(address, "http://") ||
strings.HasPrefix(address, "https://")
}
// normalizeAgentURL converts agent:// prefix to http:// and cleans URL
func normalizeAgentURL(address string) string {
address = strings.TrimPrefix(address, "agent://")
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
address = "http://" + address
}
return strings.TrimSuffix(address, "/")
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"strconv"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/gorilla/mux"
)
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/gorilla/mux"
)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"net/http"
"strconv"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/plugins"
"github.com/gorilla/mux"
)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"log"
"net/http"
"github.com/container-census/container-census/internal/migration"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/migration"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
"gopkg.in/yaml.v3"
)
+3 -2
View File
@@ -7,8 +7,8 @@ import (
"strconv"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
"github.com/gorilla/mux"
)
@@ -20,6 +20,7 @@ type VulnerabilityScanner interface {
GetConfig() *vulnerability.Config
SetConfig(config *vulnerability.Config)
InvalidateCache(imageID string)
GetAgentInfo(ctx context.Context, host *models.Host) (*models.AgentInfo, error)
}
// VulnerabilityScheduler interface for the vulnerability scheduler
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"os"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
"gopkg.in/yaml.v3"
)
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"fmt"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
"gopkg.in/yaml.v3"
)
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"log"
"os"
"github.com/container-census/container-census/internal/config"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/config"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// ImportYAMLConfig imports settings from config.yaml to database (one-time migration)
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
func TestImportYAMLConfig_FirstRun_WithValidYAML(t *testing.T) {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// TestIntegration_FirstRunScenario simulates the complete first-run workflow
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
"github.com/container-census/container-census/internal/config"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/config"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// TestServerStartup_FirstRun simulates exactly what happens when the server starts for the first time
+24 -13
View File
@@ -15,13 +15,14 @@ type Host struct {
AgentToken string `json:"agent_token,omitempty"` // API token for agent authentication
AgentStatus string `json:"agent_status,omitempty"` // online, offline, unknown
AgentVersion string `json:"agent_version,omitempty"` // version of the census agent
LastSeen time.Time `json:"last_seen,omitempty"`
Enabled bool `json:"enabled"`
CollectStats bool `json:"collect_stats"` // whether to collect CPU/memory stats for this host
ContainerCount int `json:"container_count,omitempty"` // total containers on this host
RunningCount int `json:"running_count,omitempty"` // running containers on this host
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastSeen time.Time `json:"last_seen,omitempty"`
Enabled bool `json:"enabled"`
CollectStats bool `json:"collect_stats"` // whether to collect CPU/memory stats for this host
EnableVulnerabilityScanning bool `json:"enable_vulnerability_scanning"` // whether to enable vulnerability scanning for this host
ContainerCount int `json:"container_count,omitempty"` // total containers on this host
RunningCount int `json:"running_count,omitempty"` // running containers on this host
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Container represents a Docker container found on a host
@@ -202,6 +203,14 @@ type VulnerabilityConfig struct {
MaxQueueSize int `yaml:"max_queue_size"`
}
// TrivyDBMetadata tracks Trivy database version and update status per host
type TrivyDBMetadata struct {
HostID int64 `json:"host_id"`
TrivyVersion string `json:"trivy_version"`
DBVersion string `json:"db_version"`
LastUpdated time.Time `json:"last_updated"`
}
// HostConfig contains host configuration
type HostConfig struct {
Name string `yaml:"name"`
@@ -211,12 +220,14 @@ type HostConfig struct {
// AgentInfo represents agent metadata
type AgentInfo struct {
Version string `json:"version"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
DockerVersion string `json:"docker_version"`
StartedAt time.Time `json:"started_at"`
Version string `json:"version"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
DockerVersion string `json:"docker_version"`
StartedAt time.Time `json:"started_at"`
HasTrivy bool `json:"has_trivy"` // Whether Trivy is available on this agent
TrivyVersion string `json:"trivy_version"` // Trivy version if available
}
// AgentRequest wraps requests sent to agents
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"log"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// BaselineCollector manages baseline statistics for anomaly detection
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// setupTestBaseline creates a test baseline collector
+1 -1
View File
@@ -3,7 +3,7 @@ package channels
import (
"context"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Channel represents a notification delivery channel
+2 -2
View File
@@ -4,8 +4,8 @@ import (
"context"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// InAppChannel implements in-app notifications (writes to notification_log)
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// setupTestInAppChannel creates a test in-app channel with database
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// NtfyChannel implements ntfy.sh notifications
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestNtfyChannel_BasicSend tests basic ntfy notification
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"net/http"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// WebhookChannel implements webhook notifications
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestWebhookChannel_SuccessfulDelivery tests successful webhook delivery
+3 -3
View File
@@ -9,9 +9,9 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications/channels"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/notifications/channels"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// NotificationService handles all notification logic
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// setupTestNotifier creates a test notification service with an in-memory database
+1 -1
View File
@@ -3,7 +3,7 @@ package graph
import (
"fmt"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
type GraphNode struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
_ "embed"
"net/http"
"github.com/container-census/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/plugins"
)
//go:embed frontend/bundle.js
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/plugins"
"github.com/gorilla/mux"
)
File diff suppressed because one or more lines are too long
@@ -421,26 +421,20 @@ async function loadVulnerabilityScans() {
}
}
// Store scans globally for filtering
let allVulnerabilityScans = [];
// Render vulnerability scans table
function renderVulnerabilityScans(scans) {
const tbody = document.getElementById('securityScansBody');
if (!tbody) return;
// Filter out remote/agent scans (images not available for local scanning)
const localScans = scans ? scans.filter(scan => {
// Exclude scans that failed because image wasn't available (agent scans)
return !(scan.success === false && scan.error && scan.error.includes('image not available'));
}) : [];
// Count filtered scans
const filteredCount = scans ? scans.length - localScans.length : 0;
// Deduplicate by image_id (keep most recent scan)
const uniqueScans = [];
const seenImages = new Set();
// Sort by scanned_at descending to get most recent first
const sortedScans = [...localScans].sort((a, b) => {
const sortedScans = [...(scans || [])].sort((a, b) => {
const dateA = new Date(a.scanned_at);
const dateB = new Date(b.scanned_at);
return dateB - dateA;
@@ -453,36 +447,18 @@ function renderVulnerabilityScans(scans) {
}
}
// Store scans globally for filtering
allVulnerabilityScans = uniqueScans;
// Populate host filter dropdown
populateHostFilter(uniqueScans);
// Update scan count badge
const scanCountBadge = document.getElementById('scanCountBadge');
if (scanCountBadge) {
scanCountBadge.textContent = `${uniqueScans.length} images`;
}
// Update or create exclusion notice
let exclusionNotice = document.getElementById('exclusionNotice');
const tableCard = document.querySelector('.security-table-card');
if (filteredCount > 0) {
if (!exclusionNotice) {
exclusionNotice = document.createElement('div');
exclusionNotice.id = 'exclusionNotice';
exclusionNotice.className = 'security-queue-status';
exclusionNotice.style.marginBottom = '20px';
tableCard.insertBefore(exclusionNotice, tableCard.firstChild);
}
exclusionNotice.innerHTML = `
<div class="queue-status-icon"></div>
<div class="queue-status-content">
<strong>Remote Agent Images:</strong>
${filteredCount} images from remote agents are not shown. Agents do not have the ability to scan for vulnerabilities.
</div>
`;
exclusionNotice.style.display = 'flex';
} else if (exclusionNotice) {
exclusionNotice.style.display = 'none';
}
if (!uniqueScans || uniqueScans.length === 0) {
tbody.innerHTML = `<tr><td colspan="9" class="no-data">No vulnerability scans found</td></tr>`;
return;
@@ -494,21 +470,50 @@ function renderVulnerabilityScans(scans) {
const scannedAt = new Date(scan.scanned_at).toLocaleString();
let statusBadge = '';
let severityClass = '';
if (!scan.success) {
statusBadge = '❌ Failed';
severityClass = 'failed';
} else if (total === 0) {
statusBadge = '✅ Clean';
severityClass = 'clean';
} else if (counts.critical > 0) {
statusBadge = '🚨 Critical';
severityClass = 'critical';
} else if (counts.high > 0) {
statusBadge = '⚠️ High';
severityClass = 'high';
} else if (counts.medium > 0) {
statusBadge = '📊 Issues';
severityClass = 'medium';
} else {
statusBadge = '📊 Issues';
severityClass = 'low';
}
// Prepare host IDs and names for data attributes
const hostIds = (scan.host_ids || []).join(',');
const hostNames = (scan.host_names || []).join(',');
// Format host names for display (shown below image name)
const hostNamesDisplay = (scan.host_names && scan.host_names.length > 0)
? `<div style="font-size: 0.85em; color: var(--text-secondary, #666); margin-top: 4px;">
${scan.host_names.join(', ')}
</div>`
: '';
return `
<tr data-image-id="${scan.image_id}" style="cursor: pointer;" onclick="window.viewScanDetails('${scan.image_id}')">
<td title="${scan.image_name || scan.image_id}">${scan.image_name || scan.image_id}</td>
<tr data-image-id="${scan.image_id}"
data-host-ids="${hostIds}"
data-host-names="${hostNames}"
data-severity="${severityClass}"
data-image-name="${(scan.image_name || scan.image_id).toLowerCase()}"
style="cursor: pointer;"
onclick="window.viewScanDetails('${scan.image_id}')">
<td title="${scan.image_name || scan.image_id}">
<div>${scan.image_name || scan.image_id}</div>
${hostNamesDisplay}
</td>
<td>${statusBadge}</td>
<td>${total}</td>
<td>${counts.critical || 0}</td>
@@ -523,6 +528,122 @@ function renderVulnerabilityScans(scans) {
</tr>
`;
}).join('');
// Setup filter event listeners (only once)
setupFilterListeners();
}
// Populate host filter dropdown
function populateHostFilter(scans) {
const hostFilter = document.getElementById('securityHostFilter');
if (!hostFilter) return;
// Collect all unique hosts
const hostsMap = new Map();
scans.forEach(scan => {
if (scan.host_ids && scan.host_names) {
scan.host_ids.forEach((hostId, index) => {
const hostName = scan.host_names[index] || `Host ${hostId}`;
hostsMap.set(hostId, hostName);
});
}
});
// Sort hosts by name
const hosts = Array.from(hostsMap.entries()).sort((a, b) => a[1].localeCompare(b[1]));
// Preserve current selection
const currentValue = hostFilter.value;
// Rebuild dropdown
hostFilter.innerHTML = '<option value="">All Hosts</option>' +
hosts.map(([id, name]) => `<option value="${id}">${name}</option>`).join('');
// Restore selection if it still exists
if (currentValue && Array.from(hostFilter.options).some(opt => opt.value === currentValue)) {
hostFilter.value = currentValue;
}
}
// Setup filter event listeners
let filtersSetup = false;
function setupFilterListeners() {
if (filtersSetup) return;
filtersSetup = true;
const hostFilter = document.getElementById('securityHostFilter');
const severityFilter = document.getElementById('securitySeverityFilter');
const statusFilter = document.getElementById('securityStatusFilter');
const searchInput = document.getElementById('securitySearchInput');
if (hostFilter) hostFilter.addEventListener('change', applyFilters);
if (severityFilter) severityFilter.addEventListener('change', applyFilters);
if (statusFilter) statusFilter.addEventListener('change', applyFilters);
if (searchInput) searchInput.addEventListener('input', applyFilters);
}
// Apply all filters
function applyFilters() {
const hostFilter = document.getElementById('securityHostFilter')?.value || '';
const severityFilter = document.getElementById('securitySeverityFilter')?.value || '';
const statusFilter = document.getElementById('securityStatusFilter')?.value || '';
const searchText = document.getElementById('securitySearchInput')?.value.toLowerCase() || '';
const tbody = document.getElementById('securityScansBody');
if (!tbody) return;
const rows = tbody.querySelectorAll('tr[data-image-id]');
let visibleCount = 0;
rows.forEach(row => {
let show = true;
// Host filter
if (hostFilter) {
const hostIds = row.getAttribute('data-host-ids') || '';
if (!hostIds.split(',').includes(hostFilter)) {
show = false;
}
}
// Severity filter
if (severityFilter && show) {
const severity = row.getAttribute('data-severity') || '';
if (severity !== severityFilter) {
show = false;
}
}
// Status filter
if (statusFilter && show) {
const severity = row.getAttribute('data-severity') || '';
if (statusFilter === 'scanned' && (severity === 'failed' || severity === '')) {
show = false;
} else if (statusFilter === 'failed' && severity !== 'failed') {
show = false;
} else if (statusFilter === 'remote') {
// This filter doesn't apply to the table (remote scans are already filtered out)
show = false;
}
}
// Search filter
if (searchText && show) {
const imageName = row.getAttribute('data-image-name') || '';
if (!imageName.includes(searchText)) {
show = false;
}
}
row.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
// Update count badge
const scanCountBadge = document.getElementById('scanCountBadge');
if (scanCountBadge) {
scanCountBadge.textContent = `${visibleCount} images`;
}
}
// View detailed scan results
@@ -662,7 +783,16 @@ window.scanAllImages = async function() {
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const result = await response.json();
alert(`${result.images_queued} images queued for scanning`);
// Build detailed message with per-host breakdown
let message = `${result.total_queued} images queued for scanning`;
if (result.queued_by_host && Object.keys(result.queued_by_host).length > 0) {
const breakdown = Object.entries(result.queued_by_host)
.map(([host, count]) => `${host}: ${count}`)
.join(', ');
message += `\n\n${breakdown}`;
}
alert(message);
setTimeout(() => {
loadVulnerabilitySummary();
@@ -881,6 +1011,21 @@ window.filterSecurityScans = function() {
// Initialize security tab
async function initSecurityTab() {
// Attach event listeners to buttons
const scanAllBtn = document.getElementById('scanAllImagesBtn');
const updateDbBtn = document.getElementById('updateTrivyDBBtn');
const settingsBtn = document.getElementById('vulnerabilitySettingsBtn');
if (scanAllBtn) {
scanAllBtn.addEventListener('click', window.scanAllImages);
}
if (updateDbBtn) {
updateDbBtn.addEventListener('click', window.updateTrivyDB);
}
if (settingsBtn) {
settingsBtn.addEventListener('click', window.showSecuritySettings);
}
// Pre-load vulnerability scans
await preloadVulnerabilityScans();
@@ -983,6 +1128,14 @@ window.initSecurityPlugin = function(container, sdk) {
</div>
</div>
<div class="security-queue-status" style="display: flex; background-color: var(--info-bg, #e3f2fd); border-color: var(--info-border, #2196f3);">
<div class="queue-status-icon"></div>
<div class="queue-status-content">
<strong>Note:</strong>
Only images from hosts with vulnerability scanning enabled are shown here. Agents do not have the ability to scan for vulnerabilities locally.
</div>
</div>
<div class="security-charts-grid">
<div class="security-chart-card">
<div class="chart-card-header">
@@ -1012,6 +1165,9 @@ window.initSecurityPlugin = function(container, sdk) {
<span class="scan-count" id="scanCountBadge">0 scans</span>
</div>
<div class="security-filters-modern">
<select id="securityHostFilter" class="filter-select">
<option value="">All Hosts</option>
</select>
<select id="securitySeverityFilter" class="filter-select">
<option value="">All Severities</option>
<option value="critical">Critical</option>
+289 -20
View File
@@ -3,12 +3,15 @@ package security
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
"github.com/gorilla/mux"
)
@@ -184,30 +187,90 @@ func (p *SecurityPlugin) handleTriggerScan(w http.ResponseWriter, r *http.Reques
})
}
// handleScanAll queues all images for rescanning
// handleScanAll queues all images for rescanning across selected hosts
func (p *SecurityPlugin) handleScanAll(w http.ResponseWriter, r *http.Request) {
if p.vulnScheduler == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
// Get all unique images from recent scans
scans, err := p.db.GetAllVulnerabilityScans(1000)
// Try to parse request body for host IDs
var req struct {
HostIDs []int64 `json:"host_ids"` // Optional: if empty, scan all enabled hosts
}
// Parse body if present (ignore errors for backward compatibility)
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
// Get all hosts
hosts, err := p.db.GetHosts()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get scans: "+err.Error())
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
return
}
imageMap := make(map[string]string)
for _, scan := range scans {
imageMap[scan.ImageID] = scan.ImageName
// Determine which hosts to scan
hostsToScan := make(map[int64]models.Host)
if len(req.HostIDs) > 0 {
// Scan only selected hosts
for _, hostID := range req.HostIDs {
// Find and include this host
for _, host := range hosts {
if host.ID == hostID {
hostsToScan[hostID] = host
break
}
}
}
} else {
// Scan all hosts (default behavior)
for _, host := range hosts {
hostsToScan[host.ID] = host
}
}
count := p.vulnScheduler.RescanAll(imageMap)
queuedByHost := make(map[string]int)
totalQueued := 0
// Get all containers
localContainers, err := p.db.GetLatestContainers()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get containers: "+err.Error())
return
}
// Scan each selected host
for hostID, host := range hostsToScan {
hostCount := 0
imagesSeen := make(map[string]bool)
// Get containers for this host
for _, container := range localContainers {
if container.HostID == hostID && !imagesSeen[container.ImageID] {
imagesSeen[container.ImageID] = true
// Use force=true to bypass cache - user explicitly requested scan
if err := p.vulnScheduler.QueueScanWithHostForce(container.ImageID, container.Image, hostID, 5); err == nil {
hostCount++
}
}
}
if hostCount > 0 {
hostName := host.Name
if hostName == "" {
hostName = "Local"
}
queuedByHost[hostName] = hostCount
totalQueued += hostCount
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Rescan triggered",
"images_queued": count,
"message": "Multi-host scan triggered",
"queued_by_host": queuedByHost,
"total_queued": totalQueued,
})
}
@@ -222,24 +285,193 @@ func (p *SecurityPlugin) handleGetQueue(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, status)
}
// handleUpdateDB triggers an update of the Trivy vulnerability database
func (p *SecurityPlugin) handleUpdateDB(w http.ResponseWriter, r *http.Request) {
if p.vulnScanner == nil {
// handleGetProgress returns detailed scan progress information
func (p *SecurityPlugin) handleGetProgress(w http.ResponseWriter, r *http.Request) {
if p.vulnScheduler == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
status := p.vulnScheduler.GetQueueStatus()
err := p.vulnScanner.UpdateTrivyDB(ctx)
// Transform queue items into current scans format
currentScans := make([]map[string]interface{}, 0)
for _, item := range status.QueueItems {
// Only include items that are likely being processed (first N items where N = active workers)
if len(currentScans) < status.ActiveWorkers {
currentScans = append(currentScans, map[string]interface{}{
"image_id": item.ImageID,
"image_name": item.ImageName,
"host_id": item.HostID,
"host_name": item.HostName,
"started_at": item.QueuedAt,
})
}
}
response := map[string]interface{}{
"in_progress": status.InProgress,
"pending": status.Queued,
"total": status.InProgress + status.Queued,
"current_scans": currentScans,
}
respondJSON(w, http.StatusOK, response)
}
// handleGetTrivyStatus returns Trivy status for all hosts
func (p *SecurityPlugin) handleGetTrivyStatus(w http.ResponseWriter, r *http.Request) {
// Get all hosts
hosts, err := p.db.GetHosts()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update Trivy database: "+err.Error())
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
return
}
type TrivyHostStatus struct {
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
TrivyVersion string `json:"trivy_version"`
DBVersion string `json:"db_version"`
LastUpdated time.Time `json:"last_updated"`
HasTrivy bool `json:"has_trivy"`
}
var hostStatuses []TrivyHostStatus
// Check each host (including unix and agent types)
for _, host := range hosts {
status := TrivyHostStatus{
HostID: host.ID,
HostName: host.Name,
HasTrivy: false,
}
// Get metadata from database
if metadata, err := p.db.GetTrivyDBMetadata(host.ID); err == nil && metadata != nil {
status.TrivyVersion = metadata.TrivyVersion
status.DBVersion = metadata.DBVersion
status.LastUpdated = metadata.LastUpdated
}
// Determine if host has Trivy based on type
if host.HostType == "unix" {
// Local unix socket - has Trivy if scanner is available
status.HasTrivy = p.vulnScanner != nil
} else if host.HostType == "agent" && p.vulnScanner != nil {
// Agent hosts - query the /info endpoint to check Trivy capability
if agentInfo, err := p.vulnScanner.GetAgentInfo(r.Context(), &host); err == nil {
status.HasTrivy = agentInfo.HasTrivy
if agentInfo.TrivyVersion != "" {
status.TrivyVersion = agentInfo.TrivyVersion
}
}
}
hostStatuses = append(hostStatuses, status)
}
response := map[string]interface{}{
"hosts": hostStatuses,
}
respondJSON(w, http.StatusOK, response)
}
// handleUpdateDB triggers an update of the Trivy vulnerability database
func (p *SecurityPlugin) handleUpdateDB(w http.ResponseWriter, r *http.Request) {
// Parse request body for host IDs (optional)
type UpdateRequest struct {
HostIDs []int64 `json:"host_ids"`
}
var req UpdateRequest
if r.Body != nil {
json.NewDecoder(r.Body).Decode(&req)
}
// Get all hosts
hosts, err := p.db.GetHosts()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get hosts: "+err.Error())
return
}
// If no host IDs specified, update local unix hosts only (default behavior)
if len(req.HostIDs) == 0 {
for _, h := range hosts {
if h.HostType == "unix" {
req.HostIDs = append(req.HostIDs, h.ID)
}
}
}
type UpdateResult struct {
HostID int64 `json:"host_id"`
HostName string `json:"host_name"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
var results []UpdateResult
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Minute)
defer cancel()
// Build host map for lookups
hostMap := make(map[int64]*models.Host)
for i := range hosts {
hostMap[hosts[i].ID] = &hosts[i]
}
for _, hostID := range req.HostIDs {
targetHost := hostMap[hostID]
result := UpdateResult{
HostID: hostID,
HostName: "Unknown",
}
if targetHost != nil {
result.HostName = targetHost.Name
}
if targetHost == nil {
result.Success = false
result.Error = "Host not found"
} else if targetHost.HostType == "unix" {
// Update local Trivy DB (unix socket host)
if p.vulnScanner == nil {
result.Success = false
result.Error = "Local vulnerability scanner not available"
} else if err := p.vulnScanner.UpdateTrivyDB(ctx); err != nil {
result.Success = false
result.Error = err.Error()
} else {
result.Success = true
// Save metadata
p.db.SaveTrivyDBMetadata(targetHost.ID, "", "")
}
} else if targetHost.HostType == "agent" {
// Update agent Trivy DB
updateErr := updateAgentTrivyDB(ctx, *targetHost)
if updateErr != nil {
result.Success = false
result.Error = updateErr.Error()
} else {
result.Success = true
// Save metadata to track last update time
p.db.SaveTrivyDBMetadata(targetHost.ID, "", "")
}
} else {
result.Success = false
result.Error = "Not a unix or agent host"
}
results = append(results, result)
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Trivy database updated successfully",
"results": results,
})
}
@@ -333,3 +565,40 @@ func (p *SecurityPlugin) handleClear(w http.ResponseWriter, r *http.Request) {
"message": "All vulnerability scans and CVE data deleted",
})
}
// updateAgentTrivyDB calls the agent's Trivy DB update endpoint
func updateAgentTrivyDB(ctx context.Context, host models.Host) error {
// Build agent URL
agentURL := host.Address
if !strings.HasPrefix(agentURL, "http://") && !strings.HasPrefix(agentURL, "https://") {
agentURL = "http://" + agentURL
}
agentURL = strings.TrimSuffix(agentURL, "/") + "/api/vulnerabilities/db-update"
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", agentURL, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Add auth token
if host.AgentToken != "" {
req.Header.Set("X-API-Token", host.AgentToken)
}
// Send request
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to call agent: %w", err)
}
defer resp.Body.Close()
// Check response
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("agent returned error %d: %s", resp.StatusCode, string(body))
}
return nil
}
+15 -62
View File
@@ -9,10 +9,10 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/plugins"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/plugins"
"github.com/selfhosters-cc/container-census/internal/scanner"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
)
//go:embed frontend/bundle.js
@@ -110,7 +110,7 @@ func (p *SecurityPlugin) Start(ctx context.Context) error {
p.vulnScanner = vulnerability.NewScanner(vulnConfig, p.db)
// Initialize vulnerability scheduler
p.vulnScheduler = vulnerability.NewScheduler(p.vulnScanner, vulnConfig)
p.vulnScheduler = vulnerability.NewScheduler(p.vulnScanner, vulnConfig, p.db)
// Start scheduler
p.vulnScheduler.Start()
@@ -149,6 +149,8 @@ func (p *SecurityPlugin) Routes() []plugins.Route {
{Path: "/scan/{imageId}", Method: "POST", Handler: p.handleTriggerScan},
{Path: "/scan-all", Method: "POST", Handler: p.handleScanAll},
{Path: "/queue", Method: "GET", Handler: p.handleGetQueue},
{Path: "/progress", Method: "GET", Handler: p.handleGetProgress},
{Path: "/trivy-status", Method: "GET", Handler: p.handleGetTrivyStatus},
{Path: "/update-db", Method: "POST", Handler: p.handleUpdateDB},
{Path: "/settings", Method: "GET", Handler: p.handleGetSettings},
{Path: "/settings", Method: "PUT", Handler: p.handleUpdateSettings},
@@ -156,65 +158,16 @@ func (p *SecurityPlugin) Routes() []plugins.Route {
}
}
// Tab returns the tab definition
// Tab returns the tab definition for the Integrations sidebar
func (p *SecurityPlugin) Tab() *plugins.TabDefinition {
contentHTML := `
<div class="security-section-modern">
<div class="security-header-modern">
<div class="security-title-group">
<h2>🛡 Vulnerability Scanner</h2>
<p class="security-subtitle">Monitor and track security vulnerabilities across all container images</p>
</div>
<div class="security-actions">
<button onclick="window.scanAllImages()" class="btn btn-primary">🔄 Scan All Images</button>
<button onclick="window.updateTrivyDB()" class="btn btn-secondary">📥 Update Database</button>
<button onclick="window.showSecuritySettings()" class="btn btn-secondary"> Settings</button>
<button onclick="window.exportVulnerabilityData()" class="btn btn-secondary">📊 Export</button>
</div>
</div>
<div id="vulnerabilitySummary"></div>
<div class="security-table-card">
<div class="security-table-header-modern">
<div class="table-title-group">
<h3>Vulnerability Scans</h3>
<span class="scan-count" id="scanCountBadge">0 scans</span>
</div>
<div class="security-filters-modern">
<input type="text" id="securitySearch" class="search-input" placeholder="🔍 Search images..." oninput="window.filterSecurityScans()">
</div>
</div>
<div class="table-container">
<table class="vulnerability-table" id="securityScansTable">
<thead>
<tr>
<th>Image Name</th>
<th>Status</th>
<th>Total Vulnerabilities</th>
<th>Severity Breakdown</th>
<th>Scanned At</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="5" class="loading">Loading vulnerability scans...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`
// Return tab definition so it appears in sidebar, but without vanilla JS content
// The Next.js page at /integrations/security provides the actual UI
return &plugins.TabDefinition{
ID: "security",
Label: "Security",
Icon: "🛡️",
Order: 12, // After containers (10), before integrations (14)
ContentHTML: contentHTML,
ScriptURL: "/bundle.js", // Relative to /api/p/security/
InitFunc: "initSecurityPlugin",
ID: "security",
Label: "Security",
Icon: "🛡️",
Order: 12, // After containers (10), before other integrations
// No ContentHTML or ScriptURL - Next.js handles the UI
}
}
@@ -1,6 +1,6 @@
package security
import "github.com/container-census/container-census/internal/plugins"
import "github.com/selfhosters-cc/container-census/internal/plugins"
// Register registers the security plugin with the plugin manager
func Register(manager *plugins.Manager) {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"net/http"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Plugin represents a Container Census plugin
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/gorilla/mux"
)
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"net/url"
"strings"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
imagetypes "github.com/docker/docker/api/types/image"
)
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
containertypes "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
imagetypes "github.com/docker/docker/api/types/image"
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestCleanupSimple is a minimal test for notification cleanup
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestCleanupOldNotifications tests clearing old notifications
+45 -10
View File
@@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
_ "github.com/mattn/go-sqlite3"
)
@@ -329,6 +329,14 @@ func (db *DB) initSchema() error {
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS trivy_db_metadata (
host_id INTEGER PRIMARY KEY,
trivy_version TEXT,
db_version TEXT,
last_updated TIMESTAMP,
FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_preferences (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
@@ -472,6 +480,23 @@ func (db *DB) runMigrations() error {
}
}
// Check if enable_vulnerability_scanning column exists in hosts table
var enableVulnScanExists int
err = db.conn.QueryRow(`
SELECT COUNT(*) FROM pragma_table_info('hosts') WHERE name='enable_vulnerability_scanning'
`).Scan(&enableVulnScanExists)
if err != nil {
return err
}
if enableVulnScanExists == 0 {
if _, err := db.conn.Exec(`ALTER TABLE hosts ADD COLUMN enable_vulnerability_scanning BOOLEAN NOT NULL DEFAULT 1`); err != nil {
if !isSQLiteColumnExistsError(err) {
return err
}
}
}
// Check if cpu_percent column exists in containers table (for stats monitoring)
var cpuPercentExists int
err = db.conn.QueryRow(`
@@ -617,14 +642,14 @@ func (db *DB) GetHosts() ([]models.Host, error) {
)
SELECT
h.id, h.name, h.address, h.description, h.host_type, h.agent_token, h.agent_status,
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.created_at, h.updated_at,
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.enable_vulnerability_scanning, h.created_at, h.updated_at,
COUNT(c.id) as container_count,
COUNT(CASE WHEN c.state = 'running' THEN c.id END) as running_count
FROM hosts h
LEFT JOIN latest_scan_per_host ls ON h.id = ls.host_id
LEFT JOIN containers c ON c.host_id = h.id AND c.scanned_at = ls.max_scanned_at
GROUP BY h.id, h.name, h.address, h.description, h.host_type, h.agent_token, h.agent_status,
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.created_at, h.updated_at
h.agent_version, h.last_seen, h.enabled, h.collect_stats, h.enable_vulnerability_scanning, h.created_at, h.updated_at
ORDER BY h.name
`)
if err != nil {
@@ -637,10 +662,10 @@ func (db *DB) GetHosts() ([]models.Host, error) {
var h models.Host
var lastSeen sql.NullTime
var agentToken, agentStatus, agentVersion sql.NullString
var collectStats sql.NullBool
var collectStats, enableVulnScanning sql.NullBool
var containerCount, runningCount int
if err := rows.Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &h.CreatedAt, &h.UpdatedAt, &containerCount, &runningCount); err != nil {
if err := rows.Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &enableVulnScanning, &h.CreatedAt, &h.UpdatedAt, &containerCount, &runningCount); err != nil {
return nil, err
}
@@ -661,6 +686,11 @@ func (db *DB) GetHosts() ([]models.Host, error) {
} else {
h.CollectStats = true // Default to true
}
if enableVulnScanning.Valid {
h.EnableVulnerabilityScanning = enableVulnScanning.Bool
} else {
h.EnableVulnerabilityScanning = true // Default to true
}
h.ContainerCount = containerCount
h.RunningCount = runningCount
@@ -676,12 +706,12 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
var h models.Host
var lastSeen sql.NullTime
var agentToken, agentStatus, agentVersion sql.NullString
var collectStats sql.NullBool
var collectStats, enableVulnScanning sql.NullBool
err := db.conn.QueryRow(`
SELECT id, name, address, description, host_type, agent_token, agent_status, agent_version, last_seen, enabled, collect_stats, created_at, updated_at
SELECT id, name, address, description, host_type, agent_token, agent_status, agent_version, last_seen, enabled, collect_stats, enable_vulnerability_scanning, created_at, updated_at
FROM hosts WHERE id = ?
`, id).Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &h.CreatedAt, &h.UpdatedAt)
`, id).Scan(&h.ID, &h.Name, &h.Address, &h.Description, &h.HostType, &agentToken, &agentStatus, &agentVersion, &lastSeen, &h.Enabled, &collectStats, &enableVulnScanning, &h.CreatedAt, &h.UpdatedAt)
if err != nil {
return nil, err
}
@@ -703,6 +733,11 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
} else {
h.CollectStats = true // Default to true
}
if enableVulnScanning.Valid {
h.EnableVulnerabilityScanning = enableVulnScanning.Bool
} else {
h.EnableVulnerabilityScanning = true // Default to true
}
return &h, nil
}
@@ -711,9 +746,9 @@ func (db *DB) GetHost(id int64) (*models.Host, error) {
func (db *DB) UpdateHost(host models.Host) error {
_, err := db.conn.Exec(`
UPDATE hosts
SET name = ?, address = ?, description = ?, host_type = ?, agent_token = ?, agent_status = ?, agent_version = ?, last_seen = ?, enabled = ?, collect_stats = ?, updated_at = CURRENT_TIMESTAMP
SET name = ?, address = ?, description = ?, host_type = ?, agent_token = ?, agent_status = ?, agent_version = ?, last_seen = ?, enabled = ?, collect_stats = ?, enable_vulnerability_scanning = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, host.Name, host.Address, host.Description, host.HostType, host.AgentToken, host.AgentStatus, host.AgentVersion, host.LastSeen, host.Enabled, host.CollectStats, host.ID)
`, host.Name, host.Address, host.Description, host.HostType, host.AgentToken, host.AgentStatus, host.AgentVersion, host.LastSeen, host.Enabled, host.CollectStats, host.EnableVulnerabilityScanning, host.ID)
return err
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// setupTestDB creates an in-memory SQLite database for testing
+1 -1
View File
@@ -3,7 +3,7 @@ package storage
import (
"log"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// InitializeDefaultNotifications creates default notification channels and rules if they don't exist
+1 -1
View File
@@ -3,7 +3,7 @@ package storage
import (
"testing"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestInitializeDefaultRules tests that default notification rules are created
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"fmt"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Notification operations
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestNotificationChannelCRUD tests Create, Read, Update, Delete for notification channels
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
func TestGetChangesReport(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"log"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// GetDefaultSettings returns default system settings
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// TestSQLDatetimeDebug directly tests SQL datetime logic
+112 -1
View File
@@ -6,7 +6,8 @@ import (
"fmt"
"time"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/vulnerability"
)
// GetVulnerabilityScan retrieves a vulnerability scan by image ID
@@ -324,6 +325,39 @@ func (db *DB) GetAllVulnerabilityScans(limit int) ([]vulnerability.Vulnerability
scan.Error = errorText.String
}
// Query host information for this image
hostQuery := `
SELECT DISTINCT ic.host_id, h.name
FROM image_containers ic
LEFT JOIN hosts h ON ic.host_id = h.id
WHERE ic.image_id = ?
ORDER BY h.name
`
hostRows, err := db.conn.Query(hostQuery, scan.ImageID)
if err != nil {
// Don't fail the entire query, just log and continue
scan.HostIDs = []int{}
scan.HostNames = []string{}
} else {
hostIDs := []int{}
hostNames := []string{}
for hostRows.Next() {
var hostID int
var hostName sql.NullString
if err := hostRows.Scan(&hostID, &hostName); err == nil {
hostIDs = append(hostIDs, hostID)
if hostName.Valid {
hostNames = append(hostNames, hostName.String)
} else {
hostNames = append(hostNames, fmt.Sprintf("Host %d", hostID))
}
}
}
hostRows.Close()
scan.HostIDs = hostIDs
scan.HostNames = hostNames
}
scans = append(scans, scan)
}
@@ -496,3 +530,80 @@ func (db *DB) LoadVulnerabilitySettings() (*vulnerability.Config, error) {
return &config, nil
}
// SaveTrivyDBMetadata saves Trivy database metadata for a host
func (db *DB) SaveTrivyDBMetadata(hostID int64, trivyVersion, dbVersion string) error {
query := `
INSERT OR REPLACE INTO trivy_db_metadata (host_id, trivy_version, db_version, last_updated)
VALUES (?, ?, ?, ?)
`
_, err := db.conn.Exec(query, hostID, trivyVersion, dbVersion, time.Now())
if err != nil {
return fmt.Errorf("failed to save trivy metadata: %w", err)
}
return nil
}
// GetTrivyDBMetadata retrieves Trivy database metadata for a specific host
func (db *DB) GetTrivyDBMetadata(hostID int64) (*models.TrivyDBMetadata, error) {
query := `
SELECT host_id, trivy_version, db_version, last_updated
FROM trivy_db_metadata
WHERE host_id = ?
`
var metadata models.TrivyDBMetadata
err := db.conn.QueryRow(query, hostID).Scan(
&metadata.HostID,
&metadata.TrivyVersion,
&metadata.DBVersion,
&metadata.LastUpdated,
)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get trivy metadata: %w", err)
}
return &metadata, nil
}
// GetAllTrivyDBMetadata retrieves Trivy database metadata for all hosts
func (db *DB) GetAllTrivyDBMetadata() ([]models.TrivyDBMetadata, error) {
query := `
SELECT host_id, trivy_version, db_version, last_updated
FROM trivy_db_metadata
ORDER BY host_id
`
rows, err := db.conn.Query(query)
if err != nil {
return nil, fmt.Errorf("failed to query trivy metadata: %w", err)
}
defer rows.Close()
var metadataList []models.TrivyDBMetadata
for rows.Next() {
var metadata models.TrivyDBMetadata
err := rows.Scan(
&metadata.HostID,
&metadata.TrivyVersion,
&metadata.DBVersion,
&metadata.LastUpdated,
)
if err != nil {
return nil, fmt.Errorf("failed to scan trivy metadata: %w", err)
}
metadataList = append(metadataList, metadata)
}
if err = rows.Err(); err != nil {
return nil, fmt.Errorf("error iterating trivy metadata: %w", err)
}
return metadataList, nil
}
+3 -3
View File
@@ -11,9 +11,9 @@ import (
"strings"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/storage"
"github.com/container-census/container-census/internal/version"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/version"
"github.com/google/uuid"
)
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"log"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
"github.com/selfhosters-cc/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/scanner"
"github.com/selfhosters-cc/container-census/internal/storage"
)
// Scheduler handles periodic telemetry collection and submission
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"sync"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Submitter handles sending telemetry to multiple endpoints
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"testing"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/selfhosters-cc/container-census/internal/models"
)
// mockDB implements the database interface needed by Submitter
+8
View File
@@ -14,6 +14,9 @@ import (
var (
// Version is read from .version file or defaults to "dev"
Version string
// BuildTime is set at compile time using -ldflags
BuildTime string = "unknown"
)
func init() {
@@ -53,6 +56,11 @@ func Get() string {
return Version
}
// GetBuildTime returns the build timestamp
func GetBuildTime() string {
return BuildTime
}
// GitHubRelease represents the GitHub API response for a release
type GitHubRelease struct {
TagName string `json:"tag_name"`
+8 -4
View File
@@ -39,6 +39,8 @@ type VulnerabilityScan struct {
TrivyDBVersion string `json:"trivy_db_version"`
TotalVulnerabilities int `json:"total_vulnerabilities"`
SeverityCounts SeverityCounts `json:"severity_counts"`
HostIDs []int `json:"host_ids,omitempty"`
HostNames []string `json:"host_names,omitempty"`
}
// VulnerabilityScanResult is the complete result of scanning an image
@@ -59,10 +61,12 @@ type ScanSummary struct {
// ScanJob represents a queued scan job
type ScanJob struct {
ImageID string
ImageName string
Priority int // Higher = more important
QueuedAt time.Time
ImageID string `json:"image_id"`
ImageName string `json:"image_name"`
HostID int64 `json:"host_id"` // Host where image is located (for routing to agent)
HostName string `json:"host_name"` // Host name for display in UI
Priority int `json:"priority"` // Higher = more important
QueuedAt time.Time `json:"queued_at"`
}
// ScanQueueStatus provides information about the scan queue
+110
View File
@@ -5,13 +5,17 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Scanner handles vulnerability scanning using Trivy
@@ -260,3 +264,109 @@ func getTrivyDBVersion() string {
return "unknown"
}
// GetAgentInfo retrieves agent information including Trivy capability
func (s *Scanner) GetAgentInfo(ctx context.Context, host *models.Host) (*models.AgentInfo, error) {
agentURL := normalizeAgentURL(host.Address) + "/info"
req, err := http.NewRequestWithContext(ctx, "GET", agentURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to connect to agent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("agent returned status %d", resp.StatusCode)
}
var info models.AgentInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
return &info, nil
}
// ScanImageOnAgent delegates vulnerability scanning to an agent
func (s *Scanner) ScanImageOnAgent(ctx context.Context, host models.Host, imageID, imageName string) (*VulnerabilityScanResult, error) {
startTime := time.Now()
reqBody := map[string]string{
"image_id": imageID,
"image_name": imageName,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
agentURL := normalizeAgentURL(host.Address) + "/api/vulnerabilities/scan"
req, err := http.NewRequestWithContext(ctx, "POST", agentURL, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("X-API-Token", host.AgentToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "close") // Disable keep-alive to avoid EOF errors
client := &http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
DisableKeepAlives: true, // Disable connection reuse to prevent EOF errors
},
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to connect to agent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("agent returned %d: %s", resp.StatusCode, body)
}
var trivyResult TrivyResult
if err := json.NewDecoder(resp.Body).Decode(&trivyResult); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
vulnerabilities := s.parseTrivyResult(&trivyResult, imageID)
severityCounts := CalculateSeverityCounts(vulnerabilities)
scan := &VulnerabilityScan{
ImageID: imageID,
ImageName: imageName,
ScannedAt: time.Now(),
ScanDurationMs: time.Since(startTime).Milliseconds(),
Success: true,
TotalVulnerabilities: severityCounts.GetTotal(),
SeverityCounts: severityCounts,
}
// Save to database
if err := s.storage.SaveVulnerabilityScan(scan, vulnerabilities); err != nil {
log.Printf("Failed to save agent scan results: %v", err)
}
s.cache.Set(scan, vulnerabilities)
return &VulnerabilityScanResult{
Scan: *scan,
Vulnerabilities: vulnerabilities,
}, nil
}
// normalizeAgentURL converts agent:// prefix to http:// and cleans URL
func normalizeAgentURL(address string) string {
address = strings.TrimPrefix(address, "agent://")
if !strings.HasPrefix(address, "http://") && !strings.HasPrefix(address, "https://") {
address = "http://" + address
}
return strings.TrimSuffix(address, "/")
}
+117 -4
View File
@@ -3,15 +3,24 @@ package vulnerability
import (
"context"
"log"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/selfhosters-cc/container-census/internal/models"
)
// HostStorage defines the interface for retrieving host information
type HostStorage interface {
GetHost(id int64) (*models.Host, error)
}
// Scheduler manages the vulnerability scanning queue and worker pool
type Scheduler struct {
scanner *Scanner
config *Config
storage HostStorage
queue chan ScanJob
workers []*worker
mu sync.RWMutex
@@ -30,12 +39,13 @@ type worker struct {
}
// NewScheduler creates a new scan scheduler
func NewScheduler(scanner *Scanner, config *Config) *Scheduler {
func NewScheduler(scanner *Scanner, config *Config, storage HostStorage) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
scanner: scanner,
config: config,
storage: storage,
queue: make(chan ScanJob, config.GetMaxQueueSize()),
ctx: ctx,
cancel: cancel,
@@ -122,6 +132,52 @@ func (s *Scheduler) QueueScan(imageID, imageName string, priority int) error {
}
}
// QueueScanWithHost adds a scan job with host context for routing to agents
func (s *Scheduler) QueueScanWithHost(imageID, imageName string, hostID int64, priority int) error {
return s.queueScanWithHostInternal(imageID, imageName, hostID, priority, false)
}
// QueueScanWithHostForce queues a scan bypassing cache checks (force rescan)
func (s *Scheduler) QueueScanWithHostForce(imageID, imageName string, hostID int64, priority int) error {
return s.queueScanWithHostInternal(imageID, imageName, hostID, priority, true)
}
func (s *Scheduler) queueScanWithHostInternal(imageID, imageName string, hostID int64, priority int, force bool) error {
// Check if scanning is enabled
if !s.config.GetEnabled() {
return nil // Silently ignore if disabled
}
// Check if image needs scanning (unless force=true)
if !force && !s.scanner.NeedsScan(imageID) {
return nil // Already scanned recently
}
// Get host name for display
hostName := "Unknown"
if s.storage != nil {
if host, err := s.storage.GetHost(hostID); err == nil && host != nil {
hostName = host.Name
}
}
job := ScanJob{
ImageID: imageID,
ImageName: imageName,
HostID: hostID, // Include host context for routing
HostName: hostName,
Priority: priority,
QueuedAt: time.Now(),
}
select {
case s.queue <- job:
return nil
default:
return nil // Queue is full, silently drop
}
}
// QueueScanBlocking adds a scan job and waits if queue is full
func (s *Scheduler) QueueScanBlocking(imageID, imageName string, priority int) error {
if !s.config.GetEnabled() {
@@ -216,13 +272,13 @@ func (w *worker) run() {
}
}
// worker.processJob processes a single scan job
// worker.processJob processes a single scan job with intelligent routing
func (w *worker) processJob(job ScanJob) {
atomic.AddInt32(&w.scheduler.inProgressCount, 1)
defer atomic.AddInt32(&w.scheduler.inProgressCount, -1)
// Perform the scan with both image ID and name
_, err := w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
// Route scan based on host context
_, err := w.routeAndScan(job)
if err != nil {
// Only log unexpected errors (not "image not available")
if err.Error() != "image not available for scanning" {
@@ -234,6 +290,63 @@ func (w *worker) processJob(job ScanJob) {
}
}
// routeAndScan determines where to scan (agent vs server) and executes
func (w *worker) routeAndScan(job ScanJob) (*VulnerabilityScanResult, error) {
// No host context = scan locally (backward compatibility for manual scans)
if job.HostID == 0 {
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
}
// Get host information from database
host, err := w.scheduler.storage.GetHost(job.HostID)
if err != nil {
log.Printf("Failed to get host for routing: %v, falling back to local scan", err)
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
}
// Check if vulnerability scanning is enabled for this host
if !host.EnableVulnerabilityScanning {
log.Printf("Vulnerability scanning disabled for host %s, skipping", host.Name)
return nil, nil // Skip silently
}
// Check if host is an agent with Trivy capability
if w.isAgentHost(host.Address) && host.AgentStatus == "online" {
// Get agent capabilities
agentInfo, err := w.scheduler.scanner.GetAgentInfo(w.scheduler.ctx, host)
if err == nil && agentInfo.HasTrivy {
// Route to agent
log.Printf("Worker %d: Routing scan for %s to agent %s", w.id, job.ImageName, host.Name)
result, err := w.scanOnAgent(job, host)
if err != nil {
log.Printf("Agent scan failed for %s on %s: %v, falling back to local", job.ImageName, host.Name, err)
// Automatic fallback to local scan
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
}
return result, nil
}
}
// Fallback to server-side scan
log.Printf("Worker %d: Scanning %s locally (host: %s)", w.id, job.ImageName, host.Name)
return w.scheduler.scanner.ScanImage(w.scheduler.ctx, job.ImageID, job.ImageName)
}
// scanOnAgent performs vulnerability scan via agent API
func (w *worker) scanOnAgent(job ScanJob, host *models.Host) (*VulnerabilityScanResult, error) {
ctx, cancel := context.WithTimeout(w.scheduler.ctx, 10*time.Minute)
defer cancel()
return w.scheduler.scanner.ScanImageOnAgent(ctx, *host, job.ImageID, job.ImageName)
}
// isAgentHost checks if address is agent-based
func (w *worker) isAgentHost(address string) bool {
return strings.HasPrefix(address, "agent://") ||
strings.HasPrefix(address, "http://") ||
strings.HasPrefix(address, "https://")
}
// dailyStatsReset resets daily counters at midnight
func (s *Scheduler) dailyStatsReset() {
ticker := time.NewTicker(1 * time.Hour)
+153 -7
View File
@@ -383,6 +383,65 @@ save_version "$NEW_VERSION"
# Start building
print_header "Starting Build Process"
# Build Next.js frontend (if building server)
if [ "$BUILD_SERVER" = true ]; then
echo ""
read -p "Build Next.js frontend? (Y/n): " build_frontend
if [[ ! $build_frontend =~ ^[Nn]$ ]]; then
print_info "Building Next.js frontend..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
if [ ! -d "$PROJECT_ROOT/web-next/node_modules" ]; then
print_info "Installing npm dependencies..."
(cd "$PROJECT_ROOT/web-next" && npm install)
fi
(cd "$PROJECT_ROOT/web-next" && npm run build)
print_success "Next.js frontend built successfully!"
print_info "Static files available in: $PROJECT_ROOT/web-next/out/"
else
print_warning "Skipping Next.js frontend build (vanilla JS will be used)"
fi
fi
# Build Graph Plugin Frontend (if building server)
if [ "$BUILD_SERVER" = true ]; then
GRAPH_PLUGIN_DIR="$PROJECT_ROOT/internal/plugins/builtin/graph/frontend"
if [ -d "$GRAPH_PLUGIN_DIR/src" ]; then
print_info "Building Graph Plugin frontend..."
if [ ! -d "$GRAPH_PLUGIN_DIR/node_modules" ]; then
print_info "Installing Graph Plugin npm dependencies..."
(cd "$GRAPH_PLUGIN_DIR" && npm install)
fi
(cd "$GRAPH_PLUGIN_DIR" && npm run build)
print_success "Graph Plugin frontend built successfully!"
fi
fi
# Build Security Plugin Frontend (if building server)
if [ "$BUILD_SERVER" = true ]; then
SECURITY_PLUGIN_DIR="$PROJECT_ROOT/internal/plugins/builtin/security/frontend"
if [ -d "$SECURITY_PLUGIN_DIR/src" ]; then
print_info "Building Security Plugin frontend..."
if [ ! -d "$SECURITY_PLUGIN_DIR/node_modules" ]; then
print_info "Installing Security Plugin npm dependencies..."
(cd "$SECURITY_PLUGIN_DIR" && npm install)
fi
(cd "$SECURITY_PLUGIN_DIR" && npm run build)
print_success "Security Plugin frontend built successfully!"
fi
fi
BUILD_SUCCESS=true
# Build server
@@ -398,13 +457,100 @@ fi
# Build agent
if [ "$BUILD_AGENT" = true ]; then
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
if [ "$PUSH_TO_REGISTRY" = true ]; then
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
fi
else
BUILD_SUCCESS=false
fi
print_header "Agent Build Options"
echo -e "Which agent variant(s) to build?"
echo -e " ${GREEN}1${NC}) Lightweight (no Trivy) - census-agent:latest"
echo -e " ${GREEN}2${NC}) With Trivy - census-agent:with-trivy"
echo -e " ${GREEN}3${NC}) Both variants"
echo ""
read -p "Choice [1-3]: " AGENT_VARIANT
case "$AGENT_VARIANT" in
1)
print_info "Building lightweight agent (no Trivy)..."
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
if [ "$PUSH_TO_REGISTRY" = true ]; then
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
fi
else
BUILD_SUCCESS=false
fi
;;
2)
print_info "Building agent with Trivy..."
# Build with Trivy using --build-arg
if docker buildx build \
--platform "$PLATFORMS" \
--build-arg DOCKER_GID=999 \
--build-arg INSTALL_TRIVY=true \
-t "census-agent:with-trivy-$NEW_VERSION" \
-t "census-agent:with-trivy" \
-f "Dockerfile.agent" \
$([ $(echo "$PLATFORMS" | tr ',' '\n' | wc -l) -eq 1 ] && echo "--load" || echo "") \
--progress=plain \
. ; then
print_success "census-agent:with-trivy built successfully"
if [ "$PUSH_TO_REGISTRY" = true ] && [ -n "$REGISTRY" ]; then
docker buildx build \
--platform "$PLATFORMS" \
--build-arg DOCKER_GID=999 \
--build-arg INSTALL_TRIVY=true \
-t "$REGISTRY/census-agent:with-trivy-$NEW_VERSION" \
-t "$REGISTRY/census-agent:with-trivy" \
-f "Dockerfile.agent" \
--push \
--progress=plain \
.
print_success "Pushed to $REGISTRY/census-agent:with-trivy"
fi
else
BUILD_SUCCESS=false
fi
;;
3)
print_info "Building both agent variants..."
# Build lightweight
if build_image "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS"; then
if [ "$PUSH_TO_REGISTRY" = true ]; then
build_and_push "census-agent" "Dockerfile.agent" "$NEW_VERSION" "$PLATFORMS" "$REGISTRY"
fi
else
BUILD_SUCCESS=false
fi
# Build with Trivy
if docker buildx build \
--platform "$PLATFORMS" \
--build-arg DOCKER_GID=999 \
--build-arg INSTALL_TRIVY=true \
-t "census-agent:with-trivy-$NEW_VERSION" \
-t "census-agent:with-trivy" \
-f "Dockerfile.agent" \
$([ $(echo "$PLATFORMS" | tr ',' '\n' | wc -l) -eq 1 ] && echo "--load" || echo "") \
--progress=plain \
. ; then
print_success "census-agent:with-trivy built successfully"
if [ "$PUSH_TO_REGISTRY" = true ] && [ -n "$REGISTRY" ]; then
docker buildx build \
--platform "$PLATFORMS" \
--build-arg DOCKER_GID=999 \
--build-arg INSTALL_TRIVY=true \
-t "$REGISTRY/census-agent:with-trivy-$NEW_VERSION" \
-t "$REGISTRY/census-agent:with-trivy" \
-f "Dockerfile.agent" \
--push \
--progress=plain \
.
print_success "Pushed to $REGISTRY/census-agent:with-trivy"
fi
else
BUILD_SUCCESS=false
fi
;;
*)
print_error "Invalid choice. Skipping agent build."
;;
esac
fi
# Build telemetry collector
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
# Deploy Agent Script
# Builds agent Docker image and deploys to ubuntu3
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Configuration
REMOTE_HOST="ubuntu3"
REMOTE_COMPOSE_DIR="/opt/docker-compose"
IMAGE_NAME="census-agent"
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "999")
# Read version
VERSION=$(cat .version 2>/dev/null || echo "dev")
echo -e "${YELLOW}Building and deploying Census Agent v${VERSION}...${NC}"
echo ""
# Ask which agent variant to build
echo -e "${YELLOW}Which agent variant to build?${NC}"
echo -e " ${GREEN}1${NC}) Lightweight (no Trivy) - faster, smaller (~20MB)"
echo -e " ${GREEN}2${NC}) With Trivy - vulnerability scanning capability (~400MB)"
echo ""
read -p "Choice [1-2] (default: 1): " AGENT_VARIANT
AGENT_VARIANT=${AGENT_VARIANT:-1}
# Set build arguments and image tags based on choice
if [ "$AGENT_VARIANT" = "2" ]; then
echo -e "${GREEN}Building agent WITH Trivy...${NC}"
BUILD_ARGS="--build-arg DOCKER_GID=${DOCKER_GID} --build-arg INSTALL_TRIVY=true"
IMAGE_TAG="with-trivy"
else
echo -e "${GREEN}Building lightweight agent (no Trivy)...${NC}"
BUILD_ARGS="--build-arg DOCKER_GID=${DOCKER_GID}"
IMAGE_TAG="latest"
fi
# Step 1: Build the agent Docker image
echo ""
echo -e "${YELLOW}Step 1/5: Building agent Docker image...${NC}"
docker buildx build \
--platform linux/amd64 \
${BUILD_ARGS} \
-f Dockerfile.agent \
-t ${IMAGE_NAME}:${VERSION} \
-t ${IMAGE_NAME}:${IMAGE_TAG} \
--load \
.
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Agent image built successfully${NC}"
else
echo -e "${RED}✗ Failed to build agent image${NC}"
exit 1
fi
# Step 2: Save the image to a tar file
echo ""
echo -e "${YELLOW}Step 2/5: Saving image to tar file...${NC}"
docker save ${IMAGE_NAME}:${IMAGE_TAG} -o /tmp/${IMAGE_NAME}.tar
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Image saved to /tmp/${IMAGE_NAME}.tar${NC}"
else
echo -e "${RED}✗ Failed to save image${NC}"
exit 1
fi
# Step 3: Copy the tar file to ubuntu3
echo ""
echo -e "${YELLOW}Step 3/5: Copying image to ${REMOTE_HOST}...${NC}"
scp /tmp/${IMAGE_NAME}.tar ${REMOTE_HOST}:/tmp/
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Image copied to ${REMOTE_HOST}${NC}"
else
echo -e "${RED}✗ Failed to copy image to ${REMOTE_HOST}${NC}"
exit 1
fi
# Step 4: Load the image on ubuntu3 and restart the agent
echo ""
echo -e "${YELLOW}Step 4/5: Loading image and restarting agent on ${REMOTE_HOST}...${NC}"
ssh ${REMOTE_HOST} << EOF
echo "Loading image..."
docker load -i /tmp/census-agent.tar
echo "Tagging image for docker-compose..."
# Tag the loaded image to match what docker-compose expects
docker tag ${IMAGE_NAME}:${IMAGE_TAG} ghcr.io/selfhosters-cc/census-agent:latest
echo "Recreating agent container with new image..."
cd /opt/docker-compose
docker compose up -d census-agent
echo "Cleaning up tar file..."
rm /tmp/census-agent.tar
echo "Waiting for agent to be healthy..."
sleep 3
# Check if agent is running
if docker ps | grep -q census-agent; then
echo "✓ Agent container is running"
else
echo "✗ Agent container is not running"
exit 1
fi
EOF
if [ $? -eq 0 ]; then
echo -e "${GREEN}✓ Agent restarted successfully on ${REMOTE_HOST}${NC}"
else
echo -e "${RED}✗ Failed to restart agent on ${REMOTE_HOST}${NC}"
exit 1
fi
# Step 5: Cleanup local tar file
echo ""
echo -e "${YELLOW}Step 5/5: Cleaning up local tar file...${NC}"
rm /tmp/${IMAGE_NAME}.tar
echo -e "${GREEN}✓ Cleanup complete${NC}"
# Final status check
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Deployment Complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo -e "Agent version: ${YELLOW}${VERSION}${NC}"
echo -e "Agent variant: ${YELLOW}${IMAGE_TAG}${NC}"
echo -e "Deployed to: ${YELLOW}${REMOTE_HOST}${NC}"
echo ""
echo -e "You can check the agent status with:"
echo -e " ${YELLOW}ssh ${REMOTE_HOST} 'docker ps | grep census-agent'${NC}"
echo -e " ${YELLOW}ssh ${REMOTE_HOST} 'docker logs census-agent'${NC}"
echo ""
+12 -7
View File
@@ -12,7 +12,7 @@ NC='\033[0m' # No Color
TEST_MODE=false
RESET_DB=false
AUTH_ENABLED=false
FRONTEND="classic"
FRONTEND="nextjs" # Default to Next.js
INTERACTIVE=true
while [[ $# -gt 0 ]]; do
@@ -33,6 +33,10 @@ while [[ $# -gt 0 ]]; do
FRONTEND="nextjs"
shift
;;
--classic)
FRONTEND="classic"
shift
;;
--no-interactive|-y)
INTERACTIVE=false
shift
@@ -44,14 +48,15 @@ while [[ $# -gt 0 ]]; do
echo " --test Run in test mode (uses config-test.yaml and census-test.db)"
echo " --reset-db Reset the test database (only valid with --test)"
echo " --auth Enable authentication (username: qwerty, password: qwerty)"
echo " --nextjs Use Next.js frontend instead of classic"
echo " --nextjs Use Next.js frontend (default)"
echo " --classic Use classic vanilla JS frontend"
echo " --no-interactive,-y Run without prompts (use default/flag settings)"
echo " --help,-h Show this help message"
echo ""
echo "Examples:"
echo " $0 # Interactive mode (prompts for all options)"
echo " $0 --no-interactive # Non-interactive with defaults (normal mode, no auth, classic UI)"
echo " $0 -y --nextjs # Non-interactive with Next.js UI"
echo " $0 --no-interactive # Non-interactive with defaults (normal mode, no auth, Next.js UI)"
echo " $0 -y --classic # Non-interactive with classic UI"
echo " $0 --test --auth --nextjs -y # Test mode, auth enabled, Next.js UI, no prompts"
exit 0
;;
@@ -125,13 +130,13 @@ fi
if [ "$INTERACTIVE" = true ]; then
echo ""
echo "Frontend options:"
echo " 1) Classic (vanilla JS) - web/"
echo " 2) Next.js (React) - web-next/out/"
echo " 1) Next.js (React) - web-next/out/ [default]"
echo " 2) Classic (vanilla JS) - web/"
read -p "Choose frontend [1]: " -n 1 -r
echo
if [[ $REPLY == "2" ]]; then
FRONTEND="nextjs"
FRONTEND="classic"
fi
fi
+9 -1
View File
@@ -68,7 +68,15 @@ fi
# Build Go server
echo -e "${YELLOW}Building Go server...${NC}"
cd "$PROJECT_ROOT"
CGO_ENABLED=1 go build -o /tmp/census-server ./cmd/server
# Get build timestamp in RFC3339 format
BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
# Build with ldflags to inject build time
CGO_ENABLED=1 go build \
-ldflags "-X github.com/selfhosters-cc/container-census/internal/version.BuildTime=${BUILD_TIME}" \
-o /tmp/census-server \
./cmd/server
echo -e "${GREEN}Server built successfully!${NC}"
ls -lh /tmp/census-server
@@ -0,0 +1,67 @@
'use client';
import { useState } from 'react';
import { scanAllImages } from '@/lib/api';
import ScanHostSelectionModal from '@/components/ScanHostSelectionModal';
import ScanProgressModal from '@/components/ScanProgressModal';
import TrivyDatabaseModal from '@/components/TrivyDatabaseModal';
import AgentCapabilityBanner from '@/components/AgentCapabilityBanner';
import SecurityContent from '@/app/security.old/SecurityContent';
export default function SecurityIntegrationPage() {
const [showScanHostSelection, setShowScanHostSelection] = useState(false);
const [showScanProgress, setShowScanProgress] = useState(false);
const [showDbModal, setShowDbModal] = useState(false);
const [scannedCount, setScannedCount] = useState(0);
const handleScanAll = () => {
setShowScanHostSelection(true);
};
const handleStartScan = async (hostIds: number[]) => {
// Open progress modal FIRST, before starting the scan
// This ensures the modal is open when scans complete (even if they're instant from cache)
setShowScanProgress(true);
// Then trigger the scan and capture how many images were queued
const result = await scanAllImages(hostIds);
setScannedCount(result?.total_queued || 0);
};
const handleUpdateDb = () => {
setShowDbModal(true);
};
return (
<div className="space-y-6">
{/* Agent Capability Banner */}
<AgentCapabilityBanner />
{/* Security Content */}
<SecurityContent
onScanAll={handleScanAll}
onUpdateDb={handleUpdateDb}
/>
{/* Scan Host Selection Modal */}
<ScanHostSelectionModal
isOpen={showScanHostSelection}
onClose={() => setShowScanHostSelection(false)}
onStartScan={handleStartScan}
/>
{/* Scan Progress Modal */}
<ScanProgressModal
isOpen={showScanProgress}
onClose={() => setShowScanProgress(false)}
totalQueued={scannedCount}
/>
{/* Trivy Database Update Modal */}
<TrivyDatabaseModal
isOpen={showDbModal}
onClose={() => setShowDbModal(false)}
/>
</div>
);
}
@@ -0,0 +1,601 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import {
getVulnerabilitySummary,
getVulnerabilityScans,
getVulnerabilityDetails,
scanImage,
} from '@/lib/api';
import type { VulnerabilitySummary, VulnerabilityScan, Vulnerability } from '@/types';
// Chart.js type declaration
declare const Chart: {
new (ctx: CanvasRenderingContext2D, config: unknown): {
destroy: () => void;
update: () => void;
};
};
function SeverityBadge({ severity, count }: { severity: string; count: number }) {
const colors: Record<string, string> = {
critical: 'bg-[#ff1744] text-white',
high: 'bg-[#ff9800] text-white',
medium: 'bg-[#ffc107] text-black',
low: 'bg-[#4caf50] text-white',
};
return (
<span className={`px-2 py-0.5 text-xs rounded font-medium ${colors[severity] || 'bg-gray-500 text-white'}`}>
{count}
</span>
);
}
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; icon: string; color?: string }) {
return (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<span>{icon}</span>
<span className="text-sm text-[var(--text-tertiary)]">{label}</span>
</div>
<div className={`text-3xl font-bold ${color}`}>{value}</div>
</div>
);
}
interface VulnerabilityDetailsModalProps {
isOpen: boolean;
onClose: () => void;
imageId: string;
imageName: string;
}
function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: VulnerabilityDetailsModalProps) {
const [loading, setLoading] = useState(true);
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([]);
const [filter, setFilter] = useState('');
const [severityFilter, setSeverityFilter] = useState('');
useEffect(() => {
if (isOpen && imageId) {
setLoading(true);
getVulnerabilityDetails(imageId)
.then(data => {
setScan(data.scan);
setVulnerabilities(data.vulnerabilities || []);
})
.catch(console.error)
.finally(() => setLoading(false));
}
}, [isOpen, imageId]);
const filteredVulns = useMemo(() => {
return vulnerabilities.filter(v => {
const matchesSearch = filter === '' ||
v.vulnerability_id.toLowerCase().includes(filter.toLowerCase()) ||
v.pkg_name.toLowerCase().includes(filter.toLowerCase()) ||
(v.title || '').toLowerCase().includes(filter.toLowerCase());
const matchesSeverity = severityFilter === '' || v.severity.toLowerCase() === severityFilter.toLowerCase();
return matchesSearch && matchesSeverity;
});
}, [vulnerabilities, filter, severityFilter]);
// Compute counts from vulnerabilities array (most accurate)
const counts = useMemo(() => {
const c = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
vulnerabilities.forEach(v => {
c.total++;
const sev = v.severity.toLowerCase();
if (sev === 'critical') c.critical++;
else if (sev === 'high') c.high++;
else if (sev === 'medium') c.medium++;
else if (sev === 'low') c.low++;
});
return c;
}, [vulnerabilities]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-full max-w-4xl max-h-[80vh] flex flex-col">
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
<div>
<h2 className="text-xl font-bold">Vulnerability Details</h2>
<div className="text-sm text-[var(--text-tertiary)]">{imageName}</div>
</div>
<button onClick={onClose} className="text-2xl hover:opacity-70">×</button>
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-[var(--text-tertiary)]">Loading...</div>
</div>
) : (
<>
{/* Summary with severity badges */}
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-3 items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Critical:</span>
<SeverityBadge severity="critical" count={counts.critical} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">High:</span>
<SeverityBadge severity="high" count={counts.high} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Medium:</span>
<SeverityBadge severity="medium" count={counts.medium} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Low:</span>
<SeverityBadge severity="low" count={counts.low} />
</div>
<span className="text-sm text-[var(--text-tertiary)] ml-4">
Total: <strong>{counts.total}</strong>
</span>
</div>
{/* Filters */}
<div className="p-4 border-b border-[var(--border)] flex gap-4">
<input
type="text"
placeholder="Search CVE, package, title..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
/>
<select
value={severityFilter}
onChange={e => setSeverityFilter(e.target.value)}
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{/* Vulnerabilities List */}
<div className="flex-1 overflow-auto p-4">
{filteredVulns.length === 0 ? (
<div className="text-center py-8 text-[var(--text-tertiary)]">
{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No matching vulnerabilities'}
</div>
) : (
<div className="space-y-2">
{filteredVulns.map((v, idx) => (
<div key={idx} className="bg-[var(--bg-tertiary)] rounded p-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<SeverityBadge severity={v.severity.toLowerCase()} count={1} />
<code className="text-sm font-medium">{v.vulnerability_id}</code>
</div>
<div className="text-sm mb-1">{v.title || 'No title'}</div>
<div className="text-xs text-[var(--text-tertiary)]">
Package: {v.pkg_name} ({v.installed_version})
{v.fixed_version && <span> Fix: {v.fixed_version}</span>}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
interface SecurityContentProps {
onScanAll: () => void;
onUpdateDb: () => void;
}
export default function SecurityContent({ onScanAll, onUpdateDb }: SecurityContentProps) {
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [severityFilter, setSeverityFilter] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [detailsModal, setDetailsModal] = useState<{ imageId: string; imageName: string } | null>(null);
const severityChartRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const severityChartInstance = useRef<any>(null);
const loadData = async () => {
try {
const [summaryData, scansData] = await Promise.all([
getVulnerabilitySummary().catch(() => null),
getVulnerabilityScans(1000).catch(() => []),
]);
setSummary(summaryData);
setScans(scansData);
} catch (error) {
console.error('Failed to load security data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
// Load Chart.js from CDN
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
script.async = true;
document.body.appendChild(script);
const interval = setInterval(loadData, 30000);
return () => {
clearInterval(interval);
if (script.parentNode) script.parentNode.removeChild(script);
};
}, []);
// Helper to get severity count from scan (handles both nested and flat formats)
const getSeverityCount = (scan: VulnerabilityScan, severity: 'critical' | 'high' | 'medium' | 'low'): number => {
// Try nested severity_counts first (current API format)
if (scan.severity_counts && typeof scan.severity_counts === 'object') {
return scan.severity_counts[severity] || 0;
}
// Fallback to flat fields (legacy format)
const legacyField = `${severity}_count` as keyof VulnerabilityScan;
return (scan[legacyField] as number) || 0;
};
// Compute stats from scans (most reliable)
const stats = useMemo(() => {
let critical = 0, high = 0, medium = 0, low = 0, atRisk = 0;
scans.forEach(scan => {
if (scan.success) {
critical += getSeverityCount(scan, 'critical');
high += getSeverityCount(scan, 'high');
medium += getSeverityCount(scan, 'medium');
low += getSeverityCount(scan, 'low');
if (scan.total_vulnerabilities > 0) atRisk++;
}
});
return {
total: scans.filter(s => s.success).length,
critical,
high,
medium,
low,
atRisk,
};
}, [scans]);
// Render severity distribution chart
useEffect(() => {
if (loading || !severityChartRef.current || typeof Chart === 'undefined') return;
if (severityChartInstance.current) {
severityChartInstance.current.destroy();
}
const ctx = severityChartRef.current.getContext('2d');
if (!ctx) return;
severityChartInstance.current = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Critical', 'High', 'Medium', 'Low'],
datasets: [{
data: [stats.critical, stats.high, stats.medium, stats.low],
backgroundColor: ['#ff1744', '#ff9800', '#ffc107', '#4caf50'],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#94a3b8', padding: 15 },
},
},
},
});
return () => {
if (severityChartInstance.current) {
severityChartInstance.current.destroy();
}
};
}, [loading, stats]);
const filteredScans = useMemo(() => {
return scans.filter(scan => {
const matchesSearch = searchTerm === '' ||
scan.image_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
scan.image_id.toLowerCase().includes(searchTerm.toLowerCase());
let matchesStatus = true;
if (statusFilter === 'scanned') {
matchesStatus = scan.success === true;
} else if (statusFilter === 'remote') {
matchesStatus = scan.success !== true && Boolean(scan.error?.includes('not available') || scan.error?.includes('remote'));
} else if (statusFilter === 'failed') {
matchesStatus = scan.success !== true && !scan.error?.includes('not available');
}
let matchesSeverity = true;
if (severityFilter === 'critical') {
matchesSeverity = getSeverityCount(scan, 'critical') > 0;
} else if (severityFilter === 'high') {
matchesSeverity = getSeverityCount(scan, 'high') > 0;
} else if (severityFilter === 'medium') {
matchesSeverity = getSeverityCount(scan, 'medium') > 0;
} else if (severityFilter === 'low') {
matchesSeverity = getSeverityCount(scan, 'low') > 0;
} else if (severityFilter === 'clean') {
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success === true;
}
return matchesSearch && matchesStatus && matchesSeverity;
});
}, [scans, searchTerm, statusFilter, severityFilter]);
const handleScanImage = async (imageId: string) => {
try {
await scanImage(imageId);
await loadData();
} catch (error) {
console.error('Failed to scan image:', error);
}
};
// Determine status badge for a scan
const getStatusBadge = (scan: VulnerabilityScan) => {
if (!scan.success) {
const isRemote = scan.error?.includes('not available') || scan.error?.includes('remote');
if (isRemote) {
return <span className="px-2 py-1 text-xs rounded bg-[var(--info)] text-white">🌐 Remote</span>;
}
return <span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}> Failed</span>;
}
if (scan.total_vulnerabilities === 0) {
return <span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white"> Clean</span>;
}
if (getSeverityCount(scan, 'critical') > 0) {
return <span className="px-2 py-1 text-xs rounded bg-[#ff1744] text-white">🚨 Critical</span>;
}
if (getSeverityCount(scan, 'high') > 0) {
return <span className="px-2 py-1 text-xs rounded bg-[#ff9800] text-white"> High</span>;
}
return <span className="px-2 py-1 text-xs rounded bg-[#ffc107] text-black"> Vuln</span>;
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--text-tertiary)]">Loading...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">🛡 Security</h1>
<p className="text-sm text-[var(--text-tertiary)]">Monitor and track security vulnerabilities across all container images</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={onScanAll}
disabled={actionLoading}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{actionLoading ? '...' : '🔍 Scan All'}
</button>
<button
onClick={onUpdateDb}
disabled={actionLoading}
className="px-4 py-2 text-sm border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50"
>
{actionLoading ? '...' : '⬇️ Update DB'}
</button>
</div>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon="📊" label="Scanned Images" value={stats.total} />
<StatCard icon="🚨" label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
<StatCard icon="⚠️" label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
<StatCard icon="🛡️" label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
</div>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Severity Distribution Chart */}
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<h3 className="text-lg font-medium mb-2">Severity Distribution</h3>
<p className="text-xs text-[var(--text-tertiary)] mb-4">Current vulnerability breakdown</p>
<div className="h-64">
<canvas ref={severityChartRef}></canvas>
</div>
</div>
{/* Severity Counts */}
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<h3 className="text-lg font-medium mb-2">Vulnerability Counts</h3>
<p className="text-xs text-[var(--text-tertiary)] mb-4">Total vulnerabilities by severity</p>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ff1744]"></span>
<span>Critical</span>
</div>
<span className="text-xl font-bold text-[#ff1744]">{stats.critical}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ff9800]"></span>
<span>High</span>
</div>
<span className="text-xl font-bold text-[#ff9800]">{stats.high}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ffc107]"></span>
<span>Medium</span>
</div>
<span className="text-xl font-bold text-[#ffc107]">{stats.medium}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#4caf50]"></span>
<span>Low</span>
</div>
<span className="text-xl font-bold text-[#4caf50]">{stats.low}</span>
</div>
</div>
</div>
</div>
{/* Queue Status */}
{summary?.queue_status && (summary.queue_status.in_progress > 0 || summary.queue_status.pending > 0) && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 flex items-center gap-4">
<span className="text-xl"></span>
<span className="text-sm">
<strong>Scanning in progress:</strong> {summary.queue_status.in_progress} scanning, {summary.queue_status.pending} queued
</span>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<input
type="text"
placeholder="🔍 Search images..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Status</option>
<option value="scanned">Scanned Only</option>
<option value="remote">Remote Only</option>
<option value="failed">Failed Only</option>
</select>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="clean">Clean</option>
</select>
</div>
{/* Scans Table */}
{filteredScans.length === 0 ? (
<div className="text-center py-12 text-[var(--text-tertiary)]">
No vulnerability scans found
</div>
) : (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
<h3 className="font-medium">Vulnerability Scans</h3>
<span className="text-sm text-[var(--text-tertiary)]">{filteredScans.length} scans</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[var(--bg-tertiary)]">
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
<th className="text-center px-4 py-3 text-sm font-medium">Critical</th>
<th className="text-center px-4 py-3 text-sm font-medium">High</th>
<th className="text-center px-4 py-3 text-sm font-medium">Medium</th>
<th className="text-center px-4 py-3 text-sm font-medium">Low</th>
<th className="text-center px-4 py-3 text-sm font-medium">Total</th>
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredScans.map(scan => (
<tr
key={scan.image_id}
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
>
<td className="px-4 py-3">
<code className="text-sm">{scan.image_name}</code>
</td>
<td className="px-4 py-3">
{getStatusBadge(scan)}
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="critical" count={getSeverityCount(scan, 'critical')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="high" count={getSeverityCount(scan, 'high')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="medium" count={getSeverityCount(scan, 'medium')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="low" count={getSeverityCount(scan, 'low')} />
</td>
<td className="px-4 py-3 text-center font-medium">{scan.total_vulnerabilities}</td>
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
{new Date(scan.scanned_at).toLocaleString()}
</td>
<td className="px-4 py-3">
<button
onClick={(e) => {
e.stopPropagation();
handleScanImage(scan.image_id);
}}
className="px-2 py-1 text-sm rounded hover:bg-[var(--bg-secondary)] transition-colors"
title="Rescan"
>
🔄
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Details Modal */}
{detailsModal && (
<VulnerabilityDetailsModal
isOpen={!!detailsModal}
onClose={() => setDetailsModal(null)}
imageId={detailsModal.imageId}
imageName={detailsModal.imageName}
/>
)}
</div>
);
}
+29 -599
View File
@@ -1,622 +1,52 @@
'use client';
import { useEffect, useState, useMemo, useRef } from 'react';
import {
getVulnerabilitySummary,
getVulnerabilityScans,
getVulnerabilityDetails,
scanImage,
scanAllImages,
updateVulnerabilityDb,
} from '@/lib/api';
import type { VulnerabilitySummary, VulnerabilityScan, Vulnerability } from '@/types';
// Chart.js type declaration
declare const Chart: {
new (ctx: CanvasRenderingContext2D, config: unknown): {
destroy: () => void;
update: () => void;
};
};
function SeverityBadge({ severity, count }: { severity: string; count: number }) {
const colors: Record<string, string> = {
critical: 'bg-[#ff1744] text-white',
high: 'bg-[#ff9800] text-white',
medium: 'bg-[#ffc107] text-black',
low: 'bg-[#4caf50] text-white',
};
return (
<span className={`px-2 py-0.5 text-xs rounded font-medium ${colors[severity] || 'bg-gray-500 text-white'}`}>
{count}
</span>
);
}
function StatCard({ label, value, icon, color = 'text-[var(--text-primary)]' }: { label: string; value: number | string; icon: string; color?: string }) {
return (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<span>{icon}</span>
<span className="text-sm text-[var(--text-tertiary)]">{label}</span>
</div>
<div className={`text-3xl font-bold ${color}`}>{value}</div>
</div>
);
}
interface VulnerabilityDetailsModalProps {
isOpen: boolean;
onClose: () => void;
imageId: string;
imageName: string;
}
function VulnerabilityDetailsModal({ isOpen, onClose, imageId, imageName }: VulnerabilityDetailsModalProps) {
const [loading, setLoading] = useState(true);
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [vulnerabilities, setVulnerabilities] = useState<Vulnerability[]>([]);
const [filter, setFilter] = useState('');
const [severityFilter, setSeverityFilter] = useState('');
useEffect(() => {
if (isOpen && imageId) {
setLoading(true);
getVulnerabilityDetails(imageId)
.then(data => {
setScan(data.scan);
setVulnerabilities(data.vulnerabilities || []);
})
.catch(console.error)
.finally(() => setLoading(false));
}
}, [isOpen, imageId]);
const filteredVulns = useMemo(() => {
return vulnerabilities.filter(v => {
const matchesSearch = filter === '' ||
v.vulnerability_id.toLowerCase().includes(filter.toLowerCase()) ||
v.pkg_name.toLowerCase().includes(filter.toLowerCase()) ||
(v.title || '').toLowerCase().includes(filter.toLowerCase());
const matchesSeverity = severityFilter === '' || v.severity.toLowerCase() === severityFilter.toLowerCase();
return matchesSearch && matchesSeverity;
});
}, [vulnerabilities, filter, severityFilter]);
// Compute counts from vulnerabilities array (most accurate)
const counts = useMemo(() => {
const c = { critical: 0, high: 0, medium: 0, low: 0, total: 0 };
vulnerabilities.forEach(v => {
c.total++;
const sev = v.severity.toLowerCase();
if (sev === 'critical') c.critical++;
else if (sev === 'high') c.high++;
else if (sev === 'medium') c.medium++;
else if (sev === 'low') c.low++;
});
return c;
}, [vulnerabilities]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-full max-w-4xl max-h-[80vh] flex flex-col">
<div className="p-4 border-b border-[var(--border)] flex justify-between items-center">
<div>
<h2 className="text-xl font-bold">Vulnerability Details</h2>
<div className="text-sm text-[var(--text-tertiary)]">{imageName}</div>
</div>
<button onClick={onClose} className="text-2xl hover:opacity-70">×</button>
</div>
{loading ? (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-[var(--text-tertiary)]">Loading...</div>
</div>
) : (
<>
{/* Summary with severity badges */}
<div className="p-4 border-b border-[var(--border)] flex flex-wrap gap-3 items-center">
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Critical:</span>
<SeverityBadge severity="critical" count={counts.critical} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">High:</span>
<SeverityBadge severity="high" count={counts.high} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Medium:</span>
<SeverityBadge severity="medium" count={counts.medium} />
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--text-tertiary)]">Low:</span>
<SeverityBadge severity="low" count={counts.low} />
</div>
<span className="text-sm text-[var(--text-tertiary)] ml-4">
Total: <strong>{counts.total}</strong>
</span>
</div>
{/* Filters */}
<div className="p-4 border-b border-[var(--border)] flex gap-4">
<input
type="text"
placeholder="Search CVE, package, title..."
value={filter}
onChange={e => setFilter(e.target.value)}
className="flex-1 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
/>
<select
value={severityFilter}
onChange={e => setSeverityFilter(e.target.value)}
className="bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2 text-sm focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{/* Vulnerabilities List */}
<div className="flex-1 overflow-auto p-4">
{filteredVulns.length === 0 ? (
<div className="text-center py-8 text-[var(--text-tertiary)]">
{vulnerabilities.length === 0 ? 'No vulnerabilities found' : 'No matching vulnerabilities'}
</div>
) : (
<div className="space-y-2">
{filteredVulns.map((v, idx) => (
<div key={idx} className="bg-[var(--bg-tertiary)] rounded p-3">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<SeverityBadge severity={v.severity.toLowerCase()} count={1} />
<code className="text-sm font-medium">{v.vulnerability_id}</code>
</div>
<div className="text-sm mb-1">{v.title || 'No title'}</div>
<div className="text-xs text-[var(--text-tertiary)]">
Package: {v.pkg_name} ({v.installed_version})
{v.fixed_version && <span> Fix: {v.fixed_version}</span>}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
</div>
);
}
import { useState } from 'react';
import { scanAllImages } from '@/lib/api';
import ScanProgressModal from '@/components/ScanProgressModal';
import TrivyDatabaseModal from '@/components/TrivyDatabaseModal';
import AgentCapabilityBanner from '@/components/AgentCapabilityBanner';
import SecurityContent from './SecurityContent';
export default function SecurityPage() {
const [summary, setSummary] = useState<VulnerabilitySummary | null>(null);
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [severityFilter, setSeverityFilter] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [detailsModal, setDetailsModal] = useState<{ imageId: string; imageName: string } | null>(null);
const severityChartRef = useRef<HTMLCanvasElement>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const severityChartInstance = useRef<any>(null);
const loadData = async () => {
try {
const [summaryData, scansData] = await Promise.all([
getVulnerabilitySummary().catch(() => null),
getVulnerabilityScans(1000).catch(() => []),
]);
setSummary(summaryData);
setScans(scansData);
} catch (error) {
console.error('Failed to load security data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
// Load Chart.js from CDN
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0';
script.async = true;
document.body.appendChild(script);
const interval = setInterval(loadData, 30000);
return () => {
clearInterval(interval);
if (script.parentNode) script.parentNode.removeChild(script);
};
}, []);
// Helper to get severity count from scan (handles both nested and flat formats)
const getSeverityCount = (scan: VulnerabilityScan, severity: 'critical' | 'high' | 'medium' | 'low'): number => {
// Try nested severity_counts first (current API format)
if (scan.severity_counts && typeof scan.severity_counts === 'object') {
return scan.severity_counts[severity] || 0;
}
// Fallback to flat fields (legacy format)
const legacyField = `${severity}_count` as keyof VulnerabilityScan;
return (scan[legacyField] as number) || 0;
};
// Compute stats from scans (most reliable)
const stats = useMemo(() => {
let critical = 0, high = 0, medium = 0, low = 0, atRisk = 0;
scans.forEach(scan => {
if (scan.success) {
critical += getSeverityCount(scan, 'critical');
high += getSeverityCount(scan, 'high');
medium += getSeverityCount(scan, 'medium');
low += getSeverityCount(scan, 'low');
if (scan.total_vulnerabilities > 0) atRisk++;
}
});
return {
total: scans.filter(s => s.success).length,
critical,
high,
medium,
low,
atRisk,
};
}, [scans]);
// Render severity distribution chart
useEffect(() => {
if (loading || !severityChartRef.current || typeof Chart === 'undefined') return;
if (severityChartInstance.current) {
severityChartInstance.current.destroy();
}
const ctx = severityChartRef.current.getContext('2d');
if (!ctx) return;
severityChartInstance.current = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Critical', 'High', 'Medium', 'Low'],
datasets: [{
data: [stats.critical, stats.high, stats.medium, stats.low],
backgroundColor: ['#ff1744', '#ff9800', '#ffc107', '#4caf50'],
borderWidth: 0,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#94a3b8', padding: 15 },
},
},
},
});
return () => {
if (severityChartInstance.current) {
severityChartInstance.current.destroy();
}
};
}, [loading, stats]);
const filteredScans = useMemo(() => {
return scans.filter(scan => {
const matchesSearch = searchTerm === '' ||
scan.image_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
scan.image_id.toLowerCase().includes(searchTerm.toLowerCase());
let matchesStatus = true;
if (statusFilter === 'scanned') {
matchesStatus = scan.success === true;
} else if (statusFilter === 'remote') {
matchesStatus = scan.success !== true && Boolean(scan.error?.includes('not available') || scan.error?.includes('remote'));
} else if (statusFilter === 'failed') {
matchesStatus = scan.success !== true && !scan.error?.includes('not available');
}
let matchesSeverity = true;
if (severityFilter === 'critical') {
matchesSeverity = getSeverityCount(scan, 'critical') > 0;
} else if (severityFilter === 'high') {
matchesSeverity = getSeverityCount(scan, 'high') > 0;
} else if (severityFilter === 'medium') {
matchesSeverity = getSeverityCount(scan, 'medium') > 0;
} else if (severityFilter === 'low') {
matchesSeverity = getSeverityCount(scan, 'low') > 0;
} else if (severityFilter === 'clean') {
matchesSeverity = scan.total_vulnerabilities === 0 && scan.success === true;
}
return matchesSearch && matchesStatus && matchesSeverity;
});
}, [scans, searchTerm, statusFilter, severityFilter]);
const [showScanProgress, setShowScanProgress] = useState(false);
const [showDbModal, setShowDbModal] = useState(false);
const handleScanAll = async () => {
setActionLoading(true);
setShowScanProgress(true);
try {
await scanAllImages();
await loadData();
} catch (error) {
console.error('Failed to scan all images:', error);
} finally {
setActionLoading(false);
setShowScanProgress(false);
}
};
const handleUpdateDb = async () => {
setActionLoading(true);
try {
await updateVulnerabilityDb();
await loadData();
} catch (error) {
console.error('Failed to update vulnerability database:', error);
} finally {
setActionLoading(false);
}
const handleUpdateDb = () => {
setShowDbModal(true);
};
const handleScanImage = async (imageId: string) => {
try {
await scanImage(imageId);
await loadData();
} catch (error) {
console.error('Failed to scan image:', error);
}
};
// Determine status badge for a scan
const getStatusBadge = (scan: VulnerabilityScan) => {
if (!scan.success) {
const isRemote = scan.error?.includes('not available') || scan.error?.includes('remote');
if (isRemote) {
return <span className="px-2 py-1 text-xs rounded bg-[var(--info)] text-white">🌐 Remote</span>;
}
return <span className="px-2 py-1 text-xs rounded bg-[var(--danger)] text-white" title={scan.error}> Failed</span>;
}
if (scan.total_vulnerabilities === 0) {
return <span className="px-2 py-1 text-xs rounded bg-[var(--success)] text-white"> Clean</span>;
}
if (getSeverityCount(scan, 'critical') > 0) {
return <span className="px-2 py-1 text-xs rounded bg-[#ff1744] text-white">🚨 Critical</span>;
}
if (getSeverityCount(scan, 'high') > 0) {
return <span className="px-2 py-1 text-xs rounded bg-[#ff9800] text-white"> High</span>;
}
return <span className="px-2 py-1 text-xs rounded bg-[#ffc107] text-black"> Vuln</span>;
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-[var(--text-tertiary)]">Loading...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">🛡 Security</h1>
<p className="text-sm text-[var(--text-tertiary)]">Monitor and track security vulnerabilities across all container images</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleScanAll}
disabled={actionLoading}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:opacity-80 transition-opacity disabled:opacity-50"
>
{actionLoading ? '...' : '🔍 Scan All'}
</button>
<button
onClick={handleUpdateDb}
disabled={actionLoading}
className="px-4 py-2 text-sm border border-[var(--border)] rounded hover:bg-[var(--bg-tertiary)] transition-colors disabled:opacity-50"
>
{actionLoading ? '...' : '⬇️ Update DB'}
</button>
</div>
</div>
{/* Agent Capability Banner */}
<AgentCapabilityBanner />
{/* Summary Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard icon="📊" label="Scanned Images" value={stats.total} />
<StatCard icon="🚨" label="Critical" value={stats.critical} color={stats.critical > 0 ? 'text-[#ff1744]' : ''} />
<StatCard icon="⚠️" label="High" value={stats.high} color={stats.high > 0 ? 'text-[#ff9800]' : ''} />
<StatCard icon="🛡️" label="At Risk Images" value={stats.atRisk} color={stats.atRisk > 0 ? 'text-[var(--warning)]' : ''} />
</div>
{/* Security Content */}
<SecurityContent
onScanAll={handleScanAll}
onUpdateDb={handleUpdateDb}
/>
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Severity Distribution Chart */}
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<h3 className="text-lg font-medium mb-2">Severity Distribution</h3>
<p className="text-xs text-[var(--text-tertiary)] mb-4">Current vulnerability breakdown</p>
<div className="h-64">
<canvas ref={severityChartRef}></canvas>
</div>
</div>
{/* Scan Progress Modal */}
<ScanProgressModal
isOpen={showScanProgress}
onClose={() => setShowScanProgress(false)}
/>
{/* Severity Counts */}
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4">
<h3 className="text-lg font-medium mb-2">Vulnerability Counts</h3>
<p className="text-xs text-[var(--text-tertiary)] mb-4">Total vulnerabilities by severity</p>
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ff1744]"></span>
<span>Critical</span>
</div>
<span className="text-xl font-bold text-[#ff1744]">{stats.critical}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ff9800]"></span>
<span>High</span>
</div>
<span className="text-xl font-bold text-[#ff9800]">{stats.high}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#ffc107]"></span>
<span>Medium</span>
</div>
<span className="text-xl font-bold text-[#ffc107]">{stats.medium}</span>
</div>
<div className="flex items-center justify-between p-3 bg-[var(--bg-tertiary)] rounded">
<div className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full bg-[#4caf50]"></span>
<span>Low</span>
</div>
<span className="text-xl font-bold text-[#4caf50]">{stats.low}</span>
</div>
</div>
</div>
</div>
{/* Queue Status */}
{summary?.queue_status && (summary.queue_status.in_progress > 0 || summary.queue_status.pending > 0) && (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg p-4 flex items-center gap-4">
<span className="text-xl"></span>
<span className="text-sm">
<strong>Scanning in progress:</strong> {summary.queue_status.in_progress} scanning, {summary.queue_status.pending} queued
</span>
</div>
)}
{/* Filters */}
<div className="flex flex-wrap gap-4">
<input
type="text"
placeholder="🔍 Search images..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 min-w-[200px] bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent)]"
/>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Status</option>
<option value="scanned">Scanned Only</option>
<option value="remote">Remote Only</option>
<option value="failed">Failed Only</option>
</select>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg px-4 py-2 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
>
<option value="">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="clean">Clean</option>
</select>
</div>
{/* Scans Table */}
{filteredScans.length === 0 ? (
<div className="text-center py-12 text-[var(--text-tertiary)]">
No vulnerability scans found
</div>
) : (
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-[var(--border)] flex items-center justify-between">
<h3 className="font-medium">Vulnerability Scans</h3>
<span className="text-sm text-[var(--text-tertiary)]">{filteredScans.length} scans</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-[var(--bg-tertiary)]">
<th className="text-left px-4 py-3 text-sm font-medium">Image</th>
<th className="text-left px-4 py-3 text-sm font-medium">Status</th>
<th className="text-center px-4 py-3 text-sm font-medium">Critical</th>
<th className="text-center px-4 py-3 text-sm font-medium">High</th>
<th className="text-center px-4 py-3 text-sm font-medium">Medium</th>
<th className="text-center px-4 py-3 text-sm font-medium">Low</th>
<th className="text-center px-4 py-3 text-sm font-medium">Total</th>
<th className="text-left px-4 py-3 text-sm font-medium">Last Scan</th>
<th className="text-left px-4 py-3 text-sm font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredScans.map(scan => (
<tr
key={scan.image_id}
className="border-t border-[var(--border)] hover:bg-[var(--bg-tertiary)] cursor-pointer"
onClick={() => setDetailsModal({ imageId: scan.image_id, imageName: scan.image_name })}
>
<td className="px-4 py-3">
<code className="text-sm">{scan.image_name}</code>
</td>
<td className="px-4 py-3">
{getStatusBadge(scan)}
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="critical" count={getSeverityCount(scan, 'critical')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="high" count={getSeverityCount(scan, 'high')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="medium" count={getSeverityCount(scan, 'medium')} />
</td>
<td className="px-4 py-3 text-center">
<SeverityBadge severity="low" count={getSeverityCount(scan, 'low')} />
</td>
<td className="px-4 py-3 text-center font-medium">{scan.total_vulnerabilities}</td>
<td className="px-4 py-3 text-sm text-[var(--text-tertiary)]">
{new Date(scan.scanned_at).toLocaleString()}
</td>
<td className="px-4 py-3">
<button
onClick={(e) => {
e.stopPropagation();
handleScanImage(scan.image_id);
}}
className="px-2 py-1 text-sm rounded hover:bg-[var(--bg-secondary)] transition-colors"
title="Rescan"
>
🔄
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Details Modal */}
{detailsModal && (
<VulnerabilityDetailsModal
isOpen={!!detailsModal}
onClose={() => setDetailsModal(null)}
imageId={detailsModal.imageId}
imageName={detailsModal.imageName}
/>
)}
{/* Trivy Database Update Modal */}
<TrivyDatabaseModal
isOpen={showDbModal}
onClose={() => setShowDbModal(false)}
/>
</div>
);
}
@@ -0,0 +1,112 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { getTrivySummary } from '@/lib/api';
import type { TrivySummary } from '@/types';
const BANNER_DISMISSED_KEY = 'agent-capability-banner-dismissed';
export default function AgentCapabilityBanner() {
const [summary, setSummary] = useState<TrivySummary | null>(null);
const [isDismissed, setIsDismissed] = useState(false);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
// Check if banner was dismissed
const dismissed = localStorage.getItem(BANNER_DISMISSED_KEY);
if (dismissed === 'true') {
setIsDismissed(true);
setLoading(false);
return;
}
loadSummary();
}, []);
const loadSummary = async () => {
try {
const data = await getTrivySummary();
setSummary(data);
} catch (err) {
console.error('Failed to load Trivy summary:', err);
} finally {
setLoading(false);
}
};
const handleDismiss = () => {
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
setIsDismissed(true);
};
const handleNavigate = () => {
router.push('/hosts');
};
// Don't show if loading, dismissed, or no issues
if (loading || isDismissed || !summary) {
return null;
}
// Only show if there are hosts without Trivy or disabled
const hasIssues = summary.without_trivy > 0 || summary.disabled > 0;
if (!hasIssues) {
return null;
}
return (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 dark:border-yellow-600 p-4 mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400 dark:text-yellow-500" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<div className="text-sm text-yellow-700 dark:text-yellow-300">
<p className="font-medium">Agent Vulnerability Scanning Status</p>
<p className="mt-1">
{summary.with_trivy > 0 && (
<span className="font-semibold text-green-700 dark:text-green-400">
{summary.with_trivy} host{summary.with_trivy !== 1 ? 's' : ''} with Trivy
</span>
)}
{summary.with_trivy > 0 && (summary.without_trivy > 0 || summary.disabled > 0) && ', '}
{summary.without_trivy > 0 && (
<span className="font-semibold text-yellow-800 dark:text-yellow-400">
{summary.without_trivy} without Trivy
</span>
)}
{summary.without_trivy > 0 && summary.disabled > 0 && ', '}
{summary.disabled > 0 && (
<span className="font-semibold text-gray-700 dark:text-gray-400">
{summary.disabled} disabled
</span>
)}
.{' '}
<button
onClick={handleNavigate}
className="underline hover:text-yellow-900 dark:hover:text-yellow-200"
>
Click to manage hosts
</button>
</p>
</div>
</div>
<div className="ml-auto pl-3">
<button
onClick={handleDismiss}
className="inline-flex rounded-md text-yellow-400 hover:text-yellow-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:ring-offset-2"
>
<span className="sr-only">Dismiss</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,201 @@
'use client';
import { useEffect, useState } from 'react';
import { getTrivyStatus } from '@/lib/api';
import type { TrivyHostStatus } from '@/types';
interface ScanHostSelectionModalProps {
isOpen: boolean;
onClose: () => void;
onStartScan: (hostIds: number[]) => Promise<void>;
}
export default function ScanHostSelectionModal({ isOpen, onClose, onStartScan }: ScanHostSelectionModalProps) {
const [hosts, setHosts] = useState<TrivyHostStatus[]>([]);
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [starting, setStarting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
loadHosts();
}
}, [isOpen]);
const loadHosts = async () => {
setLoading(true);
setError(null);
try {
const data = await getTrivyStatus();
setHosts(data.hosts);
// Pre-select hosts that have Trivy installed
setSelectedHostIds(data.hosts.filter(h => h.has_trivy).map(h => h.host_id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load hosts');
} finally {
setLoading(false);
}
};
const handleToggleHost = (hostId: number) => {
setSelectedHostIds(prev =>
prev.includes(hostId)
? prev.filter(id => id !== hostId)
: [...prev, hostId]
);
};
const handleSelectAll = () => {
setSelectedHostIds(hosts.filter(h => h.has_trivy).map(h => h.host_id));
};
const handleDeselectAll = () => {
setSelectedHostIds([]);
};
const handleStart = async () => {
if (selectedHostIds.length === 0) {
setError('Please select at least one host');
return;
}
setStarting(true);
setError(null);
try {
// Close the modal first, THEN start the scan
// This ensures the progress modal opens before scans complete
onClose();
await onStartScan(selectedHostIds);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to start scan');
} finally {
setStarting(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Select Hosts to Scan
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
{error}
</div>
)}
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading hosts...</p>
</div>
) : (
<>
<div className="mb-4 flex justify-between items-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
Select hosts to scan their container images for vulnerabilities
</p>
<div className="space-x-2">
<button
onClick={handleSelectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select All
</button>
<button
onClick={handleDeselectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
<div className="space-y-2 mb-6 max-h-80 overflow-y-auto">
{hosts.map(host => (
<div
key={host.host_id}
className={`p-4 border rounded-lg ${
host.has_trivy
? 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-60'
}`}
>
<div className="flex items-start">
<input
type="checkbox"
checked={selectedHostIds.includes(host.host_id)}
onChange={() => handleToggleHost(host.host_id)}
disabled={!host.has_trivy}
className="mt-1 mr-3 h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900 dark:text-white">
{host.host_name}
</h3>
{!host.has_trivy && (
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
No Trivy
</span>
)}
</div>
{host.has_trivy ? (
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center space-x-4">
<span>Trivy: {host.trivy_version || 'Unknown'}</span>
<span>DB: {host.db_version || 'Unknown'}</span>
</div>
</div>
) : (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-500">
Trivy not available on this host - cannot scan for vulnerabilities
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
disabled={starting}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleStart}
disabled={starting || selectedHostIds.length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
>
{starting && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{starting ? 'Starting...' : `Scan ${selectedHostIds.length} Host${selectedHostIds.length !== 1 ? 's' : ''}`}
</button>
</div>
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,205 @@
'use client';
import { useEffect, useState } from 'react';
import { getScanProgress } from '@/lib/api';
import type { ScanProgress } from '@/types';
interface ScanProgressModalProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
totalQueued?: number; // Number of images that were queued for scanning
}
export default function ScanProgressModal({ isOpen, onClose, onComplete, totalQueued }: ScanProgressModalProps) {
const [progress, setProgress] = useState<ScanProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [startTime] = useState(Date.now());
const [hasSeenActivity, setHasSeenActivity] = useState(false);
const [completedAt, setCompletedAt] = useState<number | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setProgress(null);
setError(null);
setHasSeenActivity(false);
setCompletedAt(null);
return;
}
let pollInterval: NodeJS.Timeout;
const poll = async () => {
try {
const data = await getScanProgress();
setProgress(data);
setError(null);
// Track if we've seen any activity
// Consider it activity if there's anything in the queue OR if images were queued OR if this is the first poll
// (scans might complete so fast they're done before we poll)
if (data.total > 0 || data.in_progress > 0 || data.pending > 0) {
setHasSeenActivity(true);
} else if (!hasSeenActivity && (progress === null || (totalQueued && totalQueued > 0))) {
// First poll came back empty but we know images were queued
// Scans completed very quickly (likely from cache)
setHasSeenActivity(true);
}
// Check if complete
const isComplete = data.total === 0 && data.in_progress === 0 && data.pending === 0;
if (isComplete && hasSeenActivity && !completedAt) {
// Mark completion time
setCompletedAt(Date.now());
if (onComplete) onComplete();
}
// Auto-close 3 seconds after completion (only if we saw activity)
if (completedAt && Date.now() - completedAt > 3000) {
onClose();
}
// Auto-close after 5 minutes
if (Date.now() - startTime > 5 * 60 * 1000) {
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch progress');
}
};
// Initial poll
poll();
// Poll every 2 seconds
pollInterval = setInterval(poll, 2000);
return () => {
if (pollInterval) clearInterval(pollInterval);
};
}, [isOpen, onClose, onComplete, startTime, hasSeenActivity, completedAt]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-2xl w-full mx-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Scan Progress
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
{error}
</div>
)}
{progress && (
<>
{/* Overall Progress */}
<div className="mb-6">
<div className="flex justify-between text-sm text-gray-600 dark:text-gray-400 mb-2">
<span>
{progress.in_progress} scanning, {progress.pending} queued
</span>
<span>
{progress.total > 0 ? `${progress.in_progress + progress.pending} remaining` : 'Complete'}
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{
width: progress.total > 0 ? `${((progress.in_progress / progress.total) * 100)}%` : '100%'
}}
></div>
</div>
</div>
{/* Current Scans */}
{progress.current_scans && progress.current_scans.length > 0 ? (
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Currently Scanning
</h3>
<div className="space-y-2 max-h-60 overflow-y-auto">
{progress.current_scans.map((scan, idx) => (
<div
key={`${scan.image_id}-${idx}`}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 dark:text-white truncate">
{scan.image_name}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
on {scan.host_name}
</div>
</div>
<div className="ml-4">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
</div>
</div>
))}
</div>
</div>
) : progress.total === 0 ? (
<div className="text-center py-8">
{hasSeenActivity ? (
<>
<svg className="mx-auto h-12 w-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
All scans complete!
</p>
{totalQueued && totalQueued > 0 && (
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">
{totalQueued} image{totalQueued !== 1 ? 's' : ''} scanned
</p>
)}
</>
) : (
<>
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
No images to scan
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-500">
Make sure you have containers running with images to scan
</p>
</>
)}
</div>
) : (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p className="text-sm">Waiting for scans to start...</p>
</div>
)}
</>
)}
{!progress && !error && (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading...</p>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,252 @@
'use client';
import { useEffect, useState } from 'react';
import { getTrivyStatus, updateVulnerabilityDb } from '@/lib/api';
import type { TrivyHostStatus, UpdateDBResponse } from '@/types';
interface TrivyDatabaseModalProps {
isOpen: boolean;
onClose: () => void;
onComplete?: () => void;
}
export default function TrivyDatabaseModal({ isOpen, onClose, onComplete }: TrivyDatabaseModalProps) {
const [hosts, setHosts] = useState<TrivyHostStatus[]>([]);
const [selectedHostIds, setSelectedHostIds] = useState<number[]>([]);
const [loading, setLoading] = useState(false);
const [updating, setUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);
const [results, setResults] = useState<UpdateDBResponse | null>(null);
useEffect(() => {
if (isOpen) {
loadTrivyStatus();
}
}, [isOpen]);
const loadTrivyStatus = async () => {
setLoading(true);
setError(null);
try {
const data = await getTrivyStatus();
setHosts(data.hosts);
// Pre-select hosts that have Trivy
setSelectedHostIds(data.hosts.filter(h => h.has_trivy).map(h => h.host_id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load Trivy status');
} finally {
setLoading(false);
}
};
const handleToggleHost = (hostId: number) => {
setSelectedHostIds(prev =>
prev.includes(hostId)
? prev.filter(id => id !== hostId)
: [...prev, hostId]
);
};
const handleSelectAll = () => {
setSelectedHostIds(hosts.filter(h => h.has_trivy).map(h => h.host_id));
};
const handleDeselectAll = () => {
setSelectedHostIds([]);
};
const handleUpdate = async () => {
if (selectedHostIds.length === 0) {
setError('Please select at least one host');
return;
}
setUpdating(true);
setError(null);
setResults(null);
try {
const data = await updateVulnerabilityDb(selectedHostIds);
setResults(data);
// Refresh status after update
await loadTrivyStatus();
if (onComplete) {
onComplete();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update database');
} finally {
setUpdating(false);
}
};
const formatDate = (dateStr: string) => {
if (!dateStr) return 'Never';
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Never';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return 'Less than 1 hour ago';
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
return date.toLocaleDateString();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Update Trivy Database
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 p-3 bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-700 rounded text-red-700 dark:text-red-200">
{error}
</div>
)}
{results && (
<div className="mb-4 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded">
<h3 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Update Results</h3>
<div className="space-y-1 text-sm">
{results.results.map((result, idx) => (
<div key={idx} className="flex items-center">
{result.success ? (
<svg className="w-4 h-4 text-green-600 dark:text-green-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4 text-red-600 dark:text-red-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
)}
<span className="text-blue-800 dark:text-blue-200">
{result.host_name}: {result.success ? 'Updated' : result.error || 'Failed'}
</span>
</div>
))}
</div>
</div>
)}
{loading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading hosts...</p>
</div>
) : (
<>
<div className="mb-4 flex justify-between items-center">
<p className="text-sm text-gray-600 dark:text-gray-400">
Select hosts to update their Trivy vulnerability databases
</p>
<div className="space-x-2">
<button
onClick={handleSelectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Select All
</button>
<button
onClick={handleDeselectAll}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
>
Deselect All
</button>
</div>
</div>
<div className="space-y-2 mb-6 max-h-80 overflow-y-auto">
{hosts.map(host => (
<div
key={host.host_id}
className={`p-4 border rounded-lg ${
host.has_trivy
? 'bg-white dark:bg-gray-700 border-gray-200 dark:border-gray-600'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 opacity-60'
}`}
>
<div className="flex items-start">
<input
type="checkbox"
checked={selectedHostIds.includes(host.host_id)}
onChange={() => handleToggleHost(host.host_id)}
disabled={!host.has_trivy}
className="mt-1 mr-3 h-4 w-4 text-blue-600 rounded focus:ring-blue-500"
/>
<div className="flex-1">
<div className="flex items-center justify-between">
<h3 className="font-medium text-gray-900 dark:text-white">
{host.host_name}
</h3>
{!host.has_trivy && (
<span className="text-xs px-2 py-1 bg-yellow-100 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200 rounded">
No Trivy
</span>
)}
</div>
{host.has_trivy ? (
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
<div className="flex items-center space-x-4">
<span>Version: {host.trivy_version || 'Unknown'}</span>
<span>DB: {host.db_version || 'Unknown'}</span>
</div>
<div className="text-xs mt-1">
Last updated: {formatDate(host.last_updated)}
</div>
</div>
) : (
<div className="mt-1 text-xs text-gray-500 dark:text-gray-500">
Trivy not available on this host
</div>
)}
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end space-x-3">
<button
onClick={onClose}
disabled={updating}
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleUpdate}
disabled={updating || selectedHostIds.length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 flex items-center"
>
{updating && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{updating ? 'Updating...' : `Update ${selectedHostIds.length} Host${selectedHostIds.length !== 1 ? 's' : ''}`}
</button>
</div>
</>
)}
</div>
</div>
);
}
+25 -12
View File
@@ -104,20 +104,33 @@ export default function Header({ onScan, onTelemetry }: HeaderProps) {
<span>Census</span>
</Link>
{health && (
<span className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]">
{health.update_available ? (
<a
href={health.release_url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] hover:underline"
<div className="flex items-center gap-2">
<span
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]"
title="Current version"
>
{health.update_available ? (
<a
href={health.release_url}
target="_blank"
rel="noopener noreferrer"
className="text-[var(--accent)] hover:underline"
>
v{health.version} v{health.latest_version}
</a>
) : (
`v${health.version}`
)}
</span>
{health.build_time && health.build_time !== 'unknown' && (
<span
className="text-xs px-2 py-1 rounded bg-[var(--bg-tertiary)] text-[var(--text-tertiary)]"
title={`Built: ${new Date(health.build_time).toLocaleString()}`}
>
v{health.version} v{health.latest_version}
</a>
) : (
`v${health.version}`
🔨 {new Date(health.build_time).toLocaleDateString()} {new Date(health.build_time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
)}
</span>
</div>
)}
</div>
+18 -4
View File
@@ -67,6 +67,10 @@ export const deleteHost = (id: number) =>
fetchApi<void>(`/hosts/${id}`, { method: 'DELETE' });
export const scanHost = (id: number) =>
fetchApi<void>(`/hosts/${id}/scan`, { method: 'POST' });
export const getTrivySummary = () =>
fetchApi<import('@/types').TrivySummary>('/hosts/trivy-summary');
export const getAgentInfo = (hostId: number) =>
fetchApi<{ has_trivy: boolean; trivy_version?: string }>(`/hosts/agent/${hostId}/info`);
// Images
export const getImages = () => fetchApi<import('@/types').Image[]>('/images');
@@ -82,10 +86,20 @@ export const getVulnerabilityDetails = (imageId: string) =>
);
export const scanImage = (imageId: string) =>
fetchApi<void>(`/p/security/scan/${encodeURIComponent(imageId)}`, { method: 'POST' });
export const scanAllImages = () =>
fetchApi<void>('/p/security/scan-all', { method: 'POST' });
export const updateVulnerabilityDb = () =>
fetchApi<void>('/p/security/update-db', { method: 'POST' });
export const scanAllImages = (hostIds?: number[]) =>
fetchApi<import('@/types').ScanAllResponse>('/p/security/scan-all', {
method: 'POST',
body: hostIds ? JSON.stringify({ host_ids: hostIds }) : undefined,
});
export const getScanProgress = () =>
fetchApi<import('@/types').ScanProgress>('/p/security/progress');
export const getTrivyStatus = () =>
fetchApi<import('@/types').TrivyStatusResponse>('/p/security/trivy-status');
export const updateVulnerabilityDb = (hostIds?: number[]) =>
fetchApi<import('@/types').UpdateDBResponse>('/p/security/update-db', {
method: 'POST',
body: hostIds ? JSON.stringify({ host_ids: hostIds }) : undefined,
});
export const getVulnerabilitySettings = () =>
fetchApi<Record<string, string>>('/p/security/settings');
export const updateVulnerabilitySettings = (settings: Record<string, string>) =>
+51
View File
@@ -45,6 +45,7 @@ export interface Host {
api_token?: string;
description?: string;
collect_stats: boolean;
enable_vulnerability_scanning?: boolean;
created_at: string;
updated_at: string;
last_seen?: string;
@@ -231,6 +232,7 @@ export interface Badge {
export interface HealthStatus {
status: string;
version: string;
build_time?: string;
latest_version?: string;
update_available?: boolean;
release_url?: string;
@@ -295,3 +297,52 @@ export interface ContainerLifecycleEvent {
description?: string;
restart_count?: number;
}
// Security Plugin Types
export interface ScanProgress {
in_progress: number;
pending: number;
total: number;
current_scans: Array<{
image_id: string;
image_name: string;
host_id: number;
host_name: string;
started_at: string;
}>;
}
export interface TrivyHostStatus {
host_id: number;
host_name: string;
trivy_version: string;
db_version: string;
last_updated: string;
has_trivy: boolean;
}
export interface TrivyStatusResponse {
hosts: TrivyHostStatus[];
}
export interface TrivySummary {
with_trivy: number;
without_trivy: number;
disabled: number;
total_agents: number;
}
export interface ScanAllResponse {
message: string;
queued_by_host: Record<string, number>;
total_queued: number;
}
export interface UpdateDBResponse {
results: Array<{
host_id: number;
host_name: string;
success: boolean;
error?: string;
}>;
}
+10 -3
View File
@@ -793,11 +793,18 @@ async function loadVersion() {
const badge = document.getElementById('versionBadge');
if (data.version) {
// Format build time for display
let buildTimeText = '';
if (data.build_time && data.build_time !== 'unknown') {
const buildDate = new Date(data.build_time);
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
}
if (data.update_available && data.latest_version) {
// Show update indicator
badge.innerHTML = `v${data.version} → v${data.latest_version} <span style="font-size: 1.2em;">⬆️</span>`;
badge.style.cursor = 'pointer';
badge.title = 'Click to view update';
badge.title = `Click to view update${buildTimeText}`;
badge.onclick = () => {
if (data.release_url) {
window.open(data.release_url, '_blank');
@@ -811,7 +818,7 @@ async function loadVersion() {
// No update available
badge.textContent = 'v' + data.version;
badge.style.cursor = 'default';
badge.title = 'Current version';
badge.title = `Current version${buildTimeText}`;
badge.onclick = null;
}
}
@@ -1754,7 +1761,7 @@ async function loadHottestImages(days) {
// Update subtitle
document.getElementById('hottestSubtitle').textContent =
`Based on ${data.total_installations.toLocaleString()} installations over the last ${data.period_days} days`;
`Based on ${data.total_installations.toLocaleString()} active installations over the last ${data.period_days} days`;
// Create charts
createHottestCharts(data);
+129 -2
View File
@@ -863,11 +863,18 @@ async function loadVersion() {
const badge = document.getElementById('versionBadge');
if (data.version) {
// Format build time for display
let buildTimeText = '';
if (data.build_time && data.build_time !== 'unknown') {
const buildDate = new Date(data.build_time);
buildTimeText = `\nBuilt: ${buildDate.toLocaleString()}`;
}
if (data.update_available && data.latest_version) {
// Show update indicator
badge.innerHTML = `v${data.version} → v${data.latest_version} <span style="font-size: 1.2em;">⬆️</span>`;
badge.style.cursor = 'pointer';
badge.title = 'Click to view update';
badge.title = `Click to view update${buildTimeText}`;
badge.onclick = () => {
if (data.release_url) {
window.open(data.release_url, '_blank');
@@ -881,7 +888,7 @@ async function loadVersion() {
// No update available
badge.textContent = 'v' + data.version;
badge.style.cursor = 'default';
badge.title = 'Current version';
badge.title = `Current version${buildTimeText}`;
badge.onclick = null;
}
}
@@ -1964,6 +1971,22 @@ function renderHosts(hostsData) {
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', false)" title="Click to disable stats collection">✓ Enabled</span>'
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleStatsCollection(' + host.id + ', true)" title="Click to enable stats collection">Disabled</span>';
const vulnScanningBadge = host.enable_vulnerability_scanning
? '<span class="badge badge-success" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', false)" title="Click to disable vulnerability scanning">✓ Enabled</span>'
: '<span class="badge badge-secondary" style="cursor: pointer;" onclick="toggleVulnScanning(' + host.id + ', true)" title="Click to enable vulnerability scanning">Disabled</span>';
// Show Trivy actions only for agent hosts with Trivy capability
const hasTrivyActions = host.host_type === 'agent' && host.agent_status === 'online';
const trivyActions = hasTrivyActions ? `
<div class="dropdown" style="display: inline-block;">
<button class="btn-icon" onclick="toggleHostActionsDropdown(${host.id})" title="Trivy Actions"></button>
<div id="hostActions${host.id}" class="dropdown-menu" style="display: none;">
<a class="dropdown-item" onclick="updateHostTrivyDB(${host.id})">Update Trivy DB</a>
<a class="dropdown-item" onclick="clearHostTrivyCache(${host.id})">Clear Trivy Cache</a>
</div>
</div>
` : '';
return `
<tr>
<td><strong>${escapeHtml(host.name)}</strong></td>
@@ -1971,6 +1994,7 @@ function renderHosts(hostsData) {
<td><code>${escapeHtml(host.address)}</code></td>
<td>${statusBadge}</td>
<td>${statsCollectionBadge}</td>
<td>${vulnScanningBadge}</td>
<td>${escapeHtml(host.description || '-')}</td>
<td class="time-ago">${lastSeen}</td>
<td class="actions">
@@ -1978,6 +2002,7 @@ function renderHosts(hostsData) {
? `<button class="btn-icon btn-warning" onclick="toggleHost(${host.id}, false)" title="Disable">⏸</button>`
: `<button class="btn-icon btn-success" onclick="toggleHost(${host.id}, true)" title="Enable">▶</button>`
}
${trivyActions}
<button class="btn-icon btn-delete" onclick="deleteHost(${host.id}, '${escapeAttr(host.name)}')" title="Delete">🗑</button>
</td>
</tr>
@@ -2031,6 +2056,108 @@ async function toggleStatsCollection(hostId, enable) {
}
}
async function toggleVulnScanning(hostId, enable) {
try {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
const response = await fetch(`/api/hosts/${hostId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...host, enable_vulnerability_scanning: enable })
});
if (response.ok) {
showNotification(`Vulnerability scanning ${enable ? 'enabled' : 'disabled'} successfully`, 'success');
loadData();
} else {
const error = await response.json();
showNotification('Error: ' + (error.error || 'Failed to update host'), 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
function toggleHostActionsDropdown(hostId) {
const dropdown = document.getElementById(`hostActions${hostId}`);
if (dropdown) {
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
}
}
async function updateHostTrivyDB(hostId) {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
showNotification(`Updating Trivy database on ${host.name}...`, 'info');
try {
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-update`, {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
showNotification(result.message || 'Trivy database updated successfully', 'success');
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function clearHostTrivyCache(hostId) {
const host = hosts.find(h => h.id === hostId);
if (!host) return;
if (!confirm(`Clear Trivy cache on ${host.name}?\n\nNext scan will download the database again (~500MB).`)) {
return;
}
try {
const response = await fetchWithAuth(`/api/hosts/${hostId}/trivy-clear-cache`, {
method: 'POST'
});
if (response.ok) {
showNotification('Trivy cache cleared successfully', 'success');
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function updateAllAgentTrivyDBs() {
if (!confirm('Update Trivy databases on all agents? This may take several minutes.')) {
return;
}
showNotification('Updating Trivy databases on all agents...', 'info');
try {
const response = await fetchWithAuth('/api/hosts/bulk-trivy-update', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
showNotification(`Trivy DB update initiated on ${result.updated} agent(s)`, 'success');
setTimeout(loadHosts, 2000);
} else {
const error = await response.json();
showNotification(`Failed: ${error.error || 'Unknown error'}`, 'error');
}
} catch (error) {
showNotification('Error: ' + error.message, 'error');
}
}
async function deleteHost(hostId, hostName) {
if (!confirm(`Are you sure you want to delete host "${hostName}"?\n\nThis will remove all associated container history.`)) {
return;
+6 -2
View File
@@ -664,7 +664,10 @@
<div class="hosts-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0;">Configured Hosts</h2>
<button id="addAgentBtn" class="btn btn-success">+ Add Agent Host</button>
<div style="display: flex; gap: 10px;">
<button class="btn btn-secondary" onclick="updateAllAgentTrivyDBs()">Update All Agents' Trivy DBs</button>
<button id="addAgentBtn" class="btn btn-success">+ Add Agent Host</button>
</div>
</div>
<div id="hostsTable" class="table-container">
<table>
@@ -675,6 +678,7 @@
<th>Address</th>
<th>Status</th>
<th>Stats Collection</th>
<th>Vuln Scanning</th>
<th>Description</th>
<th>Last Seen</th>
<th>Actions</th>
@@ -682,7 +686,7 @@
</thead>
<tbody id="hostsBody">
<tr>
<td colspan="8" class="loading">Loading...</td>
<td colspan="9" class="loading">Loading...</td>
</tr>
</tbody>
</table>