Fix sidebar navigation and plugin routing issues

- Always show "Manage Plugins" link in sidebar even when all plugins disabled
- Restore NPM plugin static page to avoid bundle.js 404 errors
- Remove npm from dynamic route generateStaticParams (uses static route)
- NPM plugin now properly uses its dedicated React component
- Graph and security plugins continue to use dynamic [pluginId] route

This fixes the issue where disabling all plugins made it impossible to
re-enable them, and resolves bundle.js loading errors for NPM plugin.
This commit is contained in:
Self Hosters
2025-12-07 20:22:52 -05:00
parent 8242ab3767
commit 8f960fbf68
33 changed files with 5412 additions and 112 deletions

View File

@@ -1 +1 @@
1.8.2
1.8.4

View File

@@ -21,6 +21,7 @@ import (
"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"
@@ -249,6 +250,7 @@ func main() {
// Register built-in plugins
npm.Register(pluginManager)
graph.Register(pluginManager)
security.Register(pluginManager)
// Load and start plugins
if err := pluginManager.LoadBuiltInPlugins(context.Background()); err != nil {
@@ -306,11 +308,8 @@ func main() {
}
}
// Check for updates on startup
go checkForUpdates()
// Start daily version check
go runDailyVersionCheck(ctx)
// Version checking is now handled by telemetry collector
// UI will call collector directly for version checks
// Start daily database cleanup
go runDailyDatabaseCleanup(ctx, db)
@@ -586,37 +585,6 @@ func queueImagesForScanning(containers []models.Container, hostID int64, db *sto
// Security tab to see actual scanning activity.
}
// checkForUpdates checks for new versions and logs a warning if an update is available
func checkForUpdates() {
info := version.CheckLatestVersion()
if info.Error != nil {
// Silently ignore errors during version check
log.Printf("Version check: %v", info.Error)
return
}
if info.UpdateAvailable {
log.Printf("⚠️ UPDATE AVAILABLE: Container Census %s → %s", info.CurrentVersion, info.LatestVersion)
log.Printf(" Download: %s", info.ReleaseURL)
}
}
// runDailyVersionCheck performs version checks once per day
func runDailyVersionCheck(ctx context.Context) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkForUpdates()
}
}
}
// runDailyDatabaseCleanup performs database cleanup of redundant scans once per day
func runDailyDatabaseCleanup(ctx context.Context, db *storage.DB) {
// Run first cleanup after 1 hour (let system stabilize)

View File

@@ -123,6 +123,9 @@ func main() {
// Create weekly snapshot on startup if needed, then schedule weekly
go server.runWeeklySnapshotJob(bgCtx)
// Start daily cleanup job for old version checks
go server.runDailyCleanup(bgCtx)
// Start server
go func() {
log.Printf("Telemetry collector listening on http://0.0.0.0%s", addr)
@@ -157,12 +160,16 @@ func (s *Server) setupRoutes() {
// Ingest endpoint - always public (anonymous telemetry submission)
s.router.HandleFunc("/api/ingest", s.handleIngest).Methods("POST")
// Version check endpoint - always public (no auth required), with CORS
s.router.HandleFunc("/api/version/check", s.corsMiddleware(s.handleVersionCheck)).Methods("POST", "OPTIONS")
// Stats API - protected by API key (read-only analytics data)
s.router.HandleFunc("/api/stats/top-images", s.apiKeyMiddleware(s.handleTopImages)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/image-details", s.apiKeyMiddleware(s.handleImageDetails)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/growth", s.apiKeyMiddleware(s.handleGrowth)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/installations", s.apiKeyMiddleware(s.handleInstallations)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/summary", s.apiKeyMiddleware(s.handleSummary)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/active-installs", s.handleActiveInstalls).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/registries", s.apiKeyMiddleware(s.handleRegistries)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/versions", s.apiKeyMiddleware(s.handleVersions)).Methods("GET", "OPTIONS")
s.router.HandleFunc("/api/stats/activity-heatmap", s.apiKeyMiddleware(s.handleActivityHeatmap)).Methods("GET", "OPTIONS")
@@ -242,6 +249,24 @@ func (s *Server) apiKeyMiddleware(next http.HandlerFunc) http.HandlerFunc {
}
}
// CORS middleware for public endpoints
func (s *Server) corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Add CORS headers for cross-origin requests
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// Handle preflight requests
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
}
}
// Health check
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
if err := s.db.Ping(); err != nil {
@@ -302,6 +327,65 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
})
}
// Handle version check requests
func (s *Server) handleVersionCheck(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req struct {
InstallationID string `json:"installation_id"`
CurrentVersion string `json:"current_version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request")
return
}
// Validate required fields
if req.InstallationID == "" || req.CurrentVersion == "" {
respondError(w, http.StatusBadRequest, "Missing installation_id or current_version")
return
}
// Record this version check (upsert)
_, err := s.db.Exec(`
INSERT INTO version_checks (installation_id, current_version, checked_at)
VALUES ($1, $2, NOW())
ON CONFLICT (installation_id)
DO UPDATE SET current_version = $2, checked_at = NOW()
`, req.InstallationID, req.CurrentVersion)
if err != nil {
log.Printf("Error recording version check: %v", err)
// Continue anyway - don't fail the check
}
// Check GitHub API (using existing version package logic)
info := version.CheckLatestVersion()
// If there was an error checking GitHub, still return a valid response
if info.Error != nil {
log.Printf("Error checking GitHub for latest version: %v", info.Error)
respondJSON(w, http.StatusOK, map[string]interface{}{
"current_version": req.CurrentVersion,
"latest_version": "",
"update_available": false,
"release_url": "",
"checked_at": time.Now().UTC(),
"error": info.Error.Error(),
})
return
}
// Return version info
respondJSON(w, http.StatusOK, map[string]interface{}{
"current_version": req.CurrentVersion,
"latest_version": info.LatestVersion,
"update_available": info.UpdateAvailable,
"release_url": info.ReleaseURL,
"checked_at": time.Now().UTC(),
})
}
// Save telemetry to database
func (s *Server) saveTelemetry(report models.TelemetryReport) error {
eventType := "new" // Will be set to "update" if we UPDATE existing record
@@ -870,6 +954,7 @@ func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) {
TotalHosts int `json:"total_hosts"`
TotalAgents int `json:"total_agents"`
UniqueImages int `json:"unique_images"`
ActiveInstalls30d int `json:"active_installs_30d"`
}
var summary Summary
@@ -907,9 +992,85 @@ func (s *Server) handleSummary(w http.ResponseWriter, r *http.Request) {
return
}
// Get active installations count (last 30 days from version_checks)
err = s.db.QueryRow(`
SELECT COUNT(DISTINCT installation_id)
FROM version_checks
WHERE checked_at >= NOW() - INTERVAL '30 days'
`).Scan(&summary.ActiveInstalls30d)
if err != nil {
// If table doesn't exist yet or query fails, default to 0
summary.ActiveInstalls30d = 0
}
respondJSON(w, http.StatusOK, summary)
}
// Get active installations stats
func (s *Server) handleActiveInstalls(w http.ResponseWriter, r *http.Request) {
// Parse time window parameter (default: 30 days)
daysStr := r.URL.Query().Get("days")
days := 30
if daysStr != "" {
if d, err := strconv.Atoi(daysStr); err == nil && d > 0 && d <= 365 {
days = d
}
}
// Query active installations (version checks within time window)
var activeInstalls int
err := s.db.QueryRow(`
SELECT COUNT(DISTINCT installation_id)
FROM version_checks
WHERE checked_at >= NOW() - INTERVAL '1 day' * $1
`, days).Scan(&activeInstalls)
if err != nil {
respondError(w, http.StatusInternalServerError, "Database query failed: "+err.Error())
return
}
// Query total unique installations ever seen
var totalInstalls int
s.db.QueryRow(`
SELECT COUNT(DISTINCT installation_id)
FROM version_checks
`).Scan(&totalInstalls)
// Query daily active installs for chart (last 30 days)
rows, err := s.db.Query(`
SELECT
DATE(checked_at) as check_date,
COUNT(DISTINCT installation_id) as count
FROM version_checks
WHERE checked_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(checked_at)
ORDER BY check_date ASC
`)
dailyData := []map[string]interface{}{}
if err == nil {
defer rows.Close()
for rows.Next() {
var date time.Time
var count int
if err := rows.Scan(&date, &count); err == nil {
dailyData = append(dailyData, map[string]interface{}{
"date": date.Format("2006-01-02"),
"count": count,
})
}
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"active_installs_30d": activeInstalls,
"total_installs_ever": totalInstalls,
"daily_active_installs": dailyData,
"checked_at": time.Now().UTC(),
})
}
// Get registry distribution stats
func (s *Server) handleRegistries(w http.ResponseWriter, r *http.Request) {
days := getQueryInt(r, "days", 30)
@@ -1397,6 +1558,15 @@ func initSchema(db *sql.DB) error {
CREATE INDEX IF NOT EXISTS idx_image_stats_weekly_week ON image_stats_weekly(week_start);
CREATE INDEX IF NOT EXISTS idx_image_stats_weekly_image ON image_stats_weekly(image);
CREATE INDEX IF NOT EXISTS idx_image_stats_weekly_first_seen ON image_stats_weekly(first_seen);
-- Version checks table for tracking active installations
CREATE TABLE IF NOT EXISTS version_checks (
installation_id VARCHAR(255) NOT NULL PRIMARY KEY,
current_version VARCHAR(50) NOT NULL,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_version_checks_checked_at ON version_checks(checked_at);
`
_, err := db.Exec(schema)
@@ -1822,6 +1992,42 @@ func (s *Server) runWeeklySnapshotJob(ctx context.Context) {
}
}
// runDailyCleanup runs daily cleanup tasks (old version checks, etc.)
func (s *Server) runDailyCleanup(ctx context.Context) {
// Run cleanup immediately on startup
s.cleanupOldVersionChecks()
// Then run daily at 3 AM UTC
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.cleanupOldVersionChecks()
}
}
}
// cleanupOldVersionChecks removes version check records older than 60 days
func (s *Server) cleanupOldVersionChecks() {
result, err := s.db.Exec(`
DELETE FROM version_checks
WHERE checked_at < NOW() - INTERVAL '60 days'
`)
if err != nil {
log.Printf("Error cleaning up old version checks: %v", err)
return
}
rowsAffected, _ := result.RowsAffected()
if rowsAffected > 0 {
log.Printf("Cleaned up %d old version check records (>60 days)", rowsAffected)
}
}
// createWeeklySnapshotsIfNeeded checks if snapshots exist and backfills if needed
func (s *Server) createWeeklySnapshotsIfNeeded() error {
// Check if we have any snapshots

View File

@@ -22,6 +22,7 @@ import (
"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/google/uuid"
"github.com/gorilla/mux"
)
@@ -168,6 +169,9 @@ func (s *Server) setupRoutes() {
// Health endpoint for monitoring
s.router.HandleFunc("/api/health", s.handleHealth).Methods("GET", "HEAD")
// Installation ID endpoint (used by frontend for version checking)
s.router.HandleFunc("/api/installation-id", s.handleInstallationID).Methods("GET")
// Login/logout endpoints
s.router.HandleFunc("/api/login", s.handleLogin).Methods("POST")
s.router.HandleFunc("/api/logout", s.handleLogout).Methods("POST")
@@ -282,6 +286,11 @@ func (s *Server) setupRoutes() {
api.HandleFunc("/settings/migration-status", s.handleGetMigrationStatus).Methods("GET")
api.HandleFunc("/settings/migration-ack", s.handleAcknowledgeMigration).Methods("POST")
// User preferences endpoints
api.HandleFunc("/preferences/dismissed-version", s.handleGetDismissedVersion).Methods("GET")
api.HandleFunc("/preferences/dismiss-version", s.handleDismissVersion).Methods("POST")
api.HandleFunc("/preferences/dismissed-version", s.handleClearDismissedVersion).Methods("DELETE")
// Danger Zone endpoints (destructive operations)
api.HandleFunc("/settings/reset", s.handleResetSettings).Methods("POST")
api.HandleFunc("/settings/clear-history", s.handleClearContainerHistory).Methods("POST")
@@ -824,19 +833,36 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
"auth_enabled": s.authConfig.Enabled,
}
// Add update information if available
updateInfo := version.GetUpdateInfo()
if updateInfo != nil && updateInfo.Error == nil {
response["latest_version"] = updateInfo.LatestVersion
response["update_available"] = updateInfo.UpdateAvailable
if updateInfo.UpdateAvailable {
response["release_url"] = updateInfo.ReleaseURL
}
}
// Version checking is now handled by telemetry collector
// UI will call collector directly for version checks
respondJSON(w, http.StatusOK, response)
}
func (s *Server) handleInstallationID(w http.ResponseWriter, r *http.Request) {
// Installation ID is stored in /app/data (container) or ./data (local dev)
installationIDFile := "/app/data/.installation_id"
if _, err := os.Stat("/app/data"); os.IsNotExist(err) {
installationIDFile = "./data/.installation_id"
}
id, err := os.ReadFile(installationIDFile)
if err != nil {
// Generate new ID if not found
newID := uuid.New().String()
os.MkdirAll(filepath.Dir(installationIDFile), 0755)
os.WriteFile(installationIDFile, []byte(newID), 0644)
respondJSON(w, http.StatusOK, map[string]string{
"installation_id": newID,
})
return
}
respondJSON(w, http.StatusOK, map[string]string{
"installation_id": strings.TrimSpace(string(id)),
})
}
// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {

View File

@@ -366,3 +366,81 @@ func (s *Server) handleNuclearReset(w http.ResponseWriter, r *http.Request) {
"stats": stats,
})
}
// ======= USER PREFERENCES ENDPOINTS =======
// handleGetDismissedVersion returns the currently dismissed version preference
func (s *Server) handleGetDismissedVersion(w http.ResponseWriter, r *http.Request) {
version, err := s.db.GetPreference("dismissed_version")
if err != nil {
// No dismissed version set
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"dismissed_version": nil,
"dismiss_until_major": false,
})
return
}
dismissMajor, _ := s.db.GetPreference("dismiss_until_major")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"dismissed_version": version,
"dismiss_until_major": dismissMajor == "true",
})
}
// handleDismissVersion dismisses a specific version or until next major release
func (s *Server) handleDismissVersion(w http.ResponseWriter, r *http.Request) {
var req struct {
Version string `json:"version"`
DismissUntilMajor bool `json:"dismiss_until_major"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Validate version is not empty
if req.Version == "" {
http.Error(w, "Version is required", http.StatusBadRequest)
return
}
// Save dismissed version
if err := s.db.SetPreference("dismissed_version", req.Version); err != nil {
http.Error(w, fmt.Sprintf("Failed to save preference: %v", err), http.StatusInternalServerError)
return
}
// Save dismiss until major flag
dismissMajor := "false"
if req.DismissUntilMajor {
dismissMajor = "true"
}
if err := s.db.SetPreference("dismiss_until_major", dismissMajor); err != nil {
http.Error(w, fmt.Sprintf("Failed to save preference: %v", err), http.StatusInternalServerError)
return
}
log.Printf("User dismissed version %s (until_major: %v)", req.Version, req.DismissUntilMajor)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleClearDismissedVersion clears the dismissed version preference
func (s *Server) handleClearDismissedVersion(w http.ResponseWriter, r *http.Request) {
// Delete both preferences
if err := s.db.DeletePreference("dismissed_version", "dismiss_until_major"); err != nil {
http.Error(w, fmt.Sprintf("Failed to clear preferences: %v", err), http.StatusInternalServerError)
return
}
log.Println("User cleared dismissed version preferences")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,19 @@
{
"name": "census-security-plugin",
"version": "1.0.0",
"description": "Frontend for Container Census Security/Vulnerability Plugin",
"main": "dist/bundle.js",
"scripts": {
"build": "webpack --mode production",
"dev": "webpack --mode development --watch"
},
"keywords": ["container-census", "plugin", "security", "vulnerability", "trivy"],
"author": "Container Census Team",
"license": "MIT",
"devDependencies": {
"css-loader": "^6.8.1",
"style-loader": "^3.3.3",
"webpack": "^5.89.0",
"webpack-cli": "^5.1.4"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,588 @@
/* Security Plugin Styles */
/* Vulnerability Badge on Container Cards */
.vulnerability-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
margin-left: 8px;
}
.vulnerability-badge:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.vulnerability-badge .vuln-icon {
font-size: 1rem;
}
.vulnerability-badge .vuln-count {
font-weight: 700;
}
.vulnerability-badge .vuln-severity {
font-size: 0.75rem;
opacity: 0.9;
}
/* Severity-based badge colors */
.vulnerability-badge.critical {
background: linear-gradient(135deg, #ff1744 0%, #d50000 100%);
color: white;
border-color: #b71c1c;
}
.vulnerability-badge.critical:hover {
box-shadow: 0 4px 12px rgba(255, 23, 68, 0.4);
}
.vulnerability-badge.high {
background: linear-gradient(135deg, #ff6d00 0%, #f57c00 100%);
color: white;
border-color: #e65100;
}
.vulnerability-badge.high:hover {
box-shadow: 0 4px 12px rgba(255, 109, 0, 0.4);
}
.vulnerability-badge.medium {
background: linear-gradient(135deg, #ffc107 0%, #ff9800 100%);
color: #333;
border-color: #f57c00;
}
.vulnerability-badge.medium:hover {
box-shadow: 0 4px 12px rgba(255, 193, 7, 0.4);
}
.vulnerability-badge.low {
background: linear-gradient(135deg, #8bc34a 0%, #689f38 100%);
color: white;
border-color: #558b2f;
}
.vulnerability-badge.clean {
background: linear-gradient(135deg, #4caf50 0%, #388e3c 100%);
color: white;
border-color: #2e7d32;
}
.vulnerability-badge.not-scanned {
background: #e0e0e0;
color: #666;
border-color: #bdbdbd;
}
.vulnerability-badge.remote {
background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%);
color: white;
border-color: #6a1b9a;
}
.vulnerability-badge.scanning {
background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%);
color: white;
border-color: #1565c0;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
/* Security Tab Styles */
.security-actions {
display: flex;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 12px;
}
/* Summary Cards */
.summary-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.summary-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
transition: all 0.3s ease;
}
.summary-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.summary-card-title {
font-size: 0.85rem;
color: #666;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.summary-card-value {
font-size: 2.5rem;
font-weight: 700;
color: #333;
}
.summary-card.critical .summary-card-value {
color: #d50000;
}
.summary-card.high .summary-card-value {
color: #ff6d00;
}
.summary-card.medium .summary-card-value {
color: #ffc107;
}
.summary-card.low .summary-card-value {
color: #4caf50;
}
/* Queue Status Banner */
.queue-status-banner {
background: linear-gradient(135deg, #2196f3 0%, #1976d2 100%);
color: white;
padding: 16px 20px;
border-radius: 8px;
margin-bottom: 24px;
font-size: 0.95rem;
box-shadow: 0 2px 8px rgba(33, 150, 243, 0.3);
}
.queue-status-banner strong {
font-weight: 700;
}
/* Chart Container */
.vulnerability-chart-container {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* Vulnerability Table */
.vulnerability-table-container {
background: white;
border-radius: 10px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.vulnerability-table {
width: 100%;
border-collapse: collapse;
}
.vulnerability-table thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.vulnerability-table th {
padding: 15px;
text-align: left;
font-weight: 600;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.vulnerability-table td {
padding: 15px;
border-bottom: 1px solid #e0e0e0;
}
.vulnerability-table td:last-child {
white-space: nowrap;
min-width: 200px;
}
.vulnerability-table tbody tr:hover {
background-color: #f5f5f5;
cursor: pointer;
}
.vulnerability-table tbody tr:last-child td {
border-bottom: none;
}
.severity-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-right: 4px;
}
.severity-badge.critical {
background: #ffebee;
color: #c62828;
border: 1px solid #ef5350;
}
.severity-badge.high {
background: #fff3e0;
color: #e65100;
border: 1px solid #ff9800;
}
.severity-badge.medium {
background: #fff9c4;
color: #f57f17;
border: 1px solid #fbc02d;
}
.severity-badge.low {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #66bb6a;
}
/* Vulnerability Details Modal Content */
.modal-content.vulnerability-modal {
max-width: 1400px !important;
width: 95%;
}
.scan-metadata {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 24px;
}
.scan-metadata p {
margin: 8px 0;
font-size: 0.95rem;
}
.vulnerabilities-list h4 {
margin-bottom: 16px;
color: #333;
}
.vulnerability-filter {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.vulnerability-filter input,
.vulnerability-filter select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.vulnerability-filter input {
flex: 1;
}
.vuln-description {
max-width: 400px;
word-wrap: break-word;
white-space: normal !important;
line-height: 1.4;
}
/* More specific selector to ensure description wrapping in modal table */
.modal-content .vulnerability-table .vuln-description {
max-width: 400px;
word-wrap: break-word;
white-space: normal !important;
line-height: 1.4;
}
.vuln-table {
width: 100%;
border-collapse: collapse;
}
.vuln-table thead {
background: #f5f5f5;
}
.vuln-table th,
.vuln-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
.vuln-table th {
font-weight: 600;
color: #666;
font-size: 0.9rem;
}
.vuln-table td code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.85rem;
}
.vuln-table a {
color: #1976d2;
text-decoration: none;
}
.vuln-table a:hover {
text-decoration: underline;
}
/* Settings Modal */
.settings-modal {
max-width: 700px;
}
.settings-group {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
}
.settings-group:last-child {
border-bottom: none;
}
.settings-group h4 {
font-size: 1.1rem;
color: #333;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 2px solid #667eea;
}
.settings-group label {
display: block;
margin-bottom: 12px;
font-size: 0.95rem;
}
.settings-group input[type="checkbox"] {
margin-right: 8px;
}
.settings-group input[type="number"],
.settings-group input[type="text"] {
display: block;
width: 100%;
max-width: 300px;
padding: 8px 12px;
margin-top: 4px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 0.9rem;
}
.settings-group input[disabled] {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
}
.settings-note {
font-size: 0.85rem;
color: #666;
font-style: italic;
margin-top: 8px;
}
/* Loading States */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Empty States */
.no-data {
text-align: center;
padding: 40px 20px;
color: #666;
font-size: 0.95rem;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #666;
}
.empty-state .empty-icon {
font-size: 4rem;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-state .empty-message {
font-size: 1.2rem;
margin-bottom: 10px;
}
.empty-state .empty-description {
font-size: 0.9rem;
color: #999;
}
/* Modal Overlay */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.modal-content {
background: var(--bg-secondary);
border-radius: 12px;
width: 90%;
max-width: 900px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
border: 1px solid var(--border);
}
.modal-header {
padding: 20px 25px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
}
.modal-header h2,
.modal-header h3 {
margin: 0;
color: var(--text-primary);
max-width: calc(100% - 50px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-close {
background: none;
border: none;
font-size: 32px;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
}
.modal-close:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.modal-body {
padding: 25px;
overflow-y: auto;
flex: 1;
color: var(--text-primary);
}
.modal-footer {
padding: 15px 25px;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: flex-end;
background: var(--bg-tertiary);
}
/* Dark theme adjustments for modal content */
.modal-content .scan-metadata {
background: var(--bg-primary);
color: var(--text-primary);
}
.modal-content .vulnerability-table thead {
background: var(--bg-tertiary);
}
.modal-content .vulnerability-table th {
color: var(--text-secondary);
}
.modal-content .vulnerability-table td {
border-color: var(--border);
color: var(--text-primary);
}
.modal-content .vulnerability-table tbody tr:hover {
background: var(--bg-tertiary);
}
.modal-content .vulnerability-filter input,
.modal-content .vulnerability-filter select {
background: var(--bg-primary);
border-color: var(--border);
color: var(--text-primary);
}

View File

@@ -0,0 +1,25 @@
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname),
filename: 'bundle.js',
clean: false,
},
module: {
rules: [
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
],
},
resolve: {
extensions: ['.js'],
},
mode: 'production',
optimization: {
minimize: true,
},
};

View File

@@ -0,0 +1,335 @@
package security
import (
"context"
"encoding/json"
"net/http"
"strconv"
"time"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/vulnerability"
"github.com/gorilla/mux"
)
// respondJSON writes a JSON response
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
// respondError writes an error JSON response
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
// handleGetSummary returns an overview of all vulnerability scans
func (p *SecurityPlugin) handleGetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := p.db.GetVulnerabilitySummary()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerability summary: "+err.Error())
return
}
// Add queue status if scheduler is available
if p.vulnScheduler != nil {
queueStatus := p.vulnScheduler.GetQueueStatus()
response := map[string]interface{}{
"summary": summary,
"queue_status": queueStatus,
}
respondJSON(w, http.StatusOK, response)
return
}
respondJSON(w, http.StatusOK, summary)
}
// handleGetImage returns vulnerabilities for a specific image
func (p *SecurityPlugin) handleGetImage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
imageID := vars["imageId"]
// Get scan metadata
scan, err := p.db.GetVulnerabilityScan(imageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerability scan: "+err.Error())
return
}
if scan == nil {
respondError(w, http.StatusNotFound, "No scan found for image: "+imageID)
return
}
// Get vulnerabilities
vulns, err := p.db.GetVulnerabilities(imageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerabilities: "+err.Error())
return
}
result := vulnerability.VulnerabilityScanResult{
Scan: *scan,
Vulnerabilities: vulns,
}
respondJSON(w, http.StatusOK, result)
}
// handleGetContainer returns vulnerabilities for a specific container (via its image)
func (p *SecurityPlugin) handleGetContainer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
hostID, err := strconv.Atoi(vars["hostId"])
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid host ID")
return
}
containerID := vars["containerId"]
// Get container to find its image
containers, err := p.db.GetLatestContainers()
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get containers: "+err.Error())
return
}
var container *models.Container
for _, c := range containers {
if c.ID == containerID && c.HostID == int64(hostID) {
container = &c
break
}
}
if container == nil {
respondError(w, http.StatusNotFound, "Container not found")
return
}
// Get scan for the container's image
scan, err := p.db.GetVulnerabilityScan(container.ImageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerability scan: "+err.Error())
return
}
if scan == nil {
respondError(w, http.StatusNotFound, "No scan found for container image")
return
}
// Get vulnerabilities
vulns, err := p.db.GetVulnerabilities(container.ImageID)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerabilities: "+err.Error())
return
}
result := map[string]interface{}{
"container_id": container.ID,
"container_name": container.Name,
"host_name": container.HostName,
"image_id": container.ImageID,
"image_name": container.Image,
"scan": scan,
"vulnerabilities": vulns,
}
respondJSON(w, http.StatusOK, result)
}
// handleTriggerScan queues an image for scanning
func (p *SecurityPlugin) handleTriggerScan(w http.ResponseWriter, r *http.Request) {
if p.vulnScheduler == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
vars := mux.Vars(r)
imageID := vars["imageId"]
// Try to get image name from database
scans, err := p.db.GetAllVulnerabilityScans(1000)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to query scans: "+err.Error())
return
}
imageName := imageID // fallback to ID
for _, scan := range scans {
if scan.ImageID == imageID {
imageName = scan.ImageName
break
}
}
// Invalidate cache to force a fresh scan
if p.vulnScanner != nil {
p.vulnScanner.InvalidateCache(imageID)
}
// Force queue the scan with high priority (skip cache check)
err = p.vulnScheduler.ForceQueueScan(imageID, imageName, 10)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to queue scan: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Scan queued",
"image_id": imageID,
"estimated_time_seconds": 30,
})
}
// handleScanAll queues all images for rescanning
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)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get scans: "+err.Error())
return
}
imageMap := make(map[string]string)
for _, scan := range scans {
imageMap[scan.ImageID] = scan.ImageName
}
count := p.vulnScheduler.RescanAll(imageMap)
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Rescan triggered",
"images_queued": count,
})
}
// handleGetQueue returns the current scan queue status
func (p *SecurityPlugin) handleGetQueue(w http.ResponseWriter, r *http.Request) {
if p.vulnScheduler == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
status := p.vulnScheduler.GetQueueStatus()
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 {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
err := p.vulnScanner.UpdateTrivyDB(ctx)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to update Trivy database: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Trivy database updated successfully",
})
}
// handleGetSettings returns the current vulnerability scanner settings
func (p *SecurityPlugin) handleGetSettings(w http.ResponseWriter, r *http.Request) {
if p.vulnScanner == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
config := p.vulnScanner.GetConfig()
respondJSON(w, http.StatusOK, config)
}
// handleUpdateSettings updates the vulnerability scanner settings
func (p *SecurityPlugin) handleUpdateSettings(w http.ResponseWriter, r *http.Request) {
if p.vulnScanner == nil || p.vulnScheduler == nil {
respondError(w, http.StatusServiceUnavailable, "Vulnerability scanner not available")
return
}
var newConfig vulnerability.Config
if err := json.NewDecoder(r.Body).Decode(&newConfig); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body: "+err.Error())
return
}
// Validate and update config
currentConfig := p.vulnScanner.GetConfig()
err := currentConfig.Update(&newConfig)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid configuration: "+err.Error())
return
}
// Save to database
err = p.db.SaveVulnerabilitySettings(currentConfig)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save settings: "+err.Error())
return
}
// Update scanner and scheduler
p.vulnScanner.SetConfig(currentConfig)
p.vulnScheduler.UpdateConfig(currentConfig)
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Settings updated successfully",
"config": currentConfig,
})
}
// handleGetScans returns all vulnerability scans
func (p *SecurityPlugin) handleGetScans(w http.ResponseWriter, r *http.Request) {
// Get limit from query params (default 100)
limitStr := r.URL.Query().Get("limit")
limit := 100
if limitStr != "" {
var err error
limit, err = strconv.Atoi(limitStr)
if err != nil || limit < 1 {
limit = 100
}
}
scans, err := p.db.GetAllVulnerabilityScans(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get vulnerability scans: "+err.Error())
return
}
respondJSON(w, http.StatusOK, scans)
}
// handleClear clears all vulnerability data
func (p *SecurityPlugin) handleClear(w http.ResponseWriter, r *http.Request) {
// This endpoint clears all vulnerability scan data
// Useful for testing or resetting the vulnerability database
p.deps.Logger.Info("⚠️ DANGER ZONE: Clearing all vulnerability data...")
// Use zero retention days to delete everything
if err := p.db.CleanupOldVulnerabilityData(0, 0); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to clear vulnerability data: "+err.Error())
return
}
p.deps.Logger.Info("✅ All vulnerability data cleared")
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "All vulnerability scans and CVE data deleted",
})
}

View File

@@ -0,0 +1,353 @@
package security
import (
"context"
_ "embed"
"fmt"
"log"
"net/http"
"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"
)
//go:embed frontend/bundle.js
var bundleJS []byte
// SecurityPlugin implements vulnerability scanning as a plugin
type SecurityPlugin struct {
deps plugins.PluginDependencies
db *storage.DB // Raw database access for vulnerability methods
scanner *scanner.Scanner
vulnScanner *vulnerability.Scanner
vulnScheduler *vulnerability.Scheduler
ctx context.Context
cancel context.CancelFunc
mu sync.RWMutex
}
// NewSecurityPlugin creates a new security plugin instance
func NewSecurityPlugin() *SecurityPlugin {
return &SecurityPlugin{}
}
// Info returns plugin metadata
func (p *SecurityPlugin) Info() plugins.PluginInfo {
return plugins.PluginInfo{
ID: "security",
Name: "Security Scanner",
Version: "1.0.0",
Description: "Vulnerability scanning with Trivy integration",
Author: "Container Census Team",
Capabilities: []string{
"vulnerability_scanning",
"ui_tab",
"ui_badge",
"settings",
},
BuiltIn: true,
}
}
// Init initializes the plugin
func (p *SecurityPlugin) Init(ctx context.Context, deps plugins.PluginDependencies) error {
p.deps = deps
p.ctx, p.cancel = context.WithCancel(ctx)
// Get raw database access
// The deps.DB is a scopedPluginDB which wraps *storage.DB
// We need to access the underlying DB for vulnerability methods
p.db = p.getRawDB(deps.DB)
if p.db == nil {
return fmt.Errorf("failed to get raw database access")
}
// Get scanner from dependencies (need to add this to dependencies)
// For now, we'll initialize in Start() when we have access to the scanner
deps.Logger.Info("Security plugin initialized")
return nil
}
// getRawDB extracts the raw storage.DB from the scoped PluginDB
func (p *SecurityPlugin) getRawDB(pluginDB plugins.PluginDB) *storage.DB {
// Type assert to access the underlying DB
// The scoped plugin DB now has a GetRawDB() method for trusted built-in plugins
type dbGetter interface {
GetRawDB() *storage.DB
}
if getter, ok := pluginDB.(dbGetter); ok {
return getter.GetRawDB()
}
log.Printf("Error: Could not access raw database - plugin DB does not implement GetRawDB()")
return nil
}
// Start starts the plugin background tasks
func (p *SecurityPlugin) Start(ctx context.Context) error {
p.deps.Logger.Info("Starting Security plugin...")
// Load vulnerability configuration from database
vulnConfig, err := p.db.LoadVulnerabilitySettings()
if err != nil {
p.deps.Logger.Error(fmt.Sprintf("Failed to load vulnerability settings: %v", err))
// Use default config
vulnConfig = vulnerability.DefaultConfig()
}
if !vulnConfig.GetEnabled() {
p.deps.Logger.Info("Vulnerability scanning is disabled")
return nil
}
// Initialize vulnerability scanner
p.vulnScanner = vulnerability.NewScanner(vulnConfig, p.db)
// Initialize vulnerability scheduler
p.vulnScheduler = vulnerability.NewScheduler(p.vulnScanner, vulnConfig)
// Start scheduler
p.vulnScheduler.Start()
// Start background jobs
go p.runDailyTrivyUpdate(p.ctx, vulnConfig)
go p.runDailyCleanup(p.ctx, vulnConfig)
p.deps.Logger.Info("Security plugin started successfully")
return nil
}
// Stop stops the plugin
func (p *SecurityPlugin) Stop(ctx context.Context) error {
p.deps.Logger.Info("Stopping Security plugin...")
if p.cancel != nil {
p.cancel()
}
p.deps.Logger.Info("Security plugin stopped")
return nil
}
// Routes returns the plugin's API routes
func (p *SecurityPlugin) Routes() []plugins.Route {
return []plugins.Route{
// Bundle serving
{Path: "/bundle.js", Method: "GET", Handler: p.serveBundleJS},
// API endpoints
{Path: "/summary", Method: "GET", Handler: p.handleGetSummary},
{Path: "/scans", Method: "GET", Handler: p.handleGetScans},
{Path: "/image/{imageId}", Method: "GET", Handler: p.handleGetImage},
{Path: "/container/{hostId}/{containerId}", Method: "GET", Handler: p.handleGetContainer},
{Path: "/scan/{imageId}", Method: "POST", Handler: p.handleTriggerScan},
{Path: "/scan-all", Method: "POST", Handler: p.handleScanAll},
{Path: "/queue", Method: "GET", Handler: p.handleGetQueue},
{Path: "/update-db", Method: "POST", Handler: p.handleUpdateDB},
{Path: "/settings", Method: "GET", Handler: p.handleGetSettings},
{Path: "/settings", Method: "PUT", Handler: p.handleUpdateSettings},
{Path: "/clear", Method: "POST", Handler: p.handleClear},
}
}
// Tab returns the tab definition
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 &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",
}
}
// Badges returns badge providers
func (p *SecurityPlugin) Badges() []plugins.BadgeProvider {
// TODO: Implement vulnerability badges
return nil
}
// ContainerEnricher returns nil (not needed)
func (p *SecurityPlugin) ContainerEnricher() plugins.ContainerEnricher {
return nil
}
// Settings returns settings definition
func (p *SecurityPlugin) Settings() *plugins.SettingsDefinition {
// TODO: Implement settings schema
return nil
}
// NotificationChannelFactory returns nil (not needed)
func (p *SecurityPlugin) NotificationChannelFactory() plugins.ChannelFactory {
return nil
}
// serveBundleJS serves the embedded JavaScript bundle
func (p *SecurityPlugin) serveBundleJS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(bundleJS)
}
// runDailyTrivyUpdate runs daily Trivy database updates
func (p *SecurityPlugin) runDailyTrivyUpdate(ctx context.Context, config *vulnerability.Config) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Calculate time until 2 AM
now := time.Now()
next2AM := time.Date(now.Year(), now.Month(), now.Day(), 2, 0, 0, 0, now.Location())
if now.After(next2AM) {
next2AM = next2AM.Add(24 * time.Hour)
}
initialDelay := time.Until(next2AM)
p.deps.Logger.Info(fmt.Sprintf("Trivy DB update scheduled for %s (in %v)", next2AM.Format("2006-01-02 15:04:05"), initialDelay))
// Wait for initial delay
select {
case <-time.After(initialDelay):
p.updateTrivyDB(ctx)
case <-ctx.Done():
return
}
// Then run every 24 hours
for {
select {
case <-ticker.C:
p.updateTrivyDB(ctx)
case <-ctx.Done():
return
}
}
}
// updateTrivyDB updates the Trivy vulnerability database
func (p *SecurityPlugin) updateTrivyDB(ctx context.Context) {
p.deps.Logger.Info("Running daily Trivy database update...")
if p.vulnScanner == nil {
p.deps.Logger.Error("Vulnerability scanner not initialized")
return
}
updateCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if err := p.vulnScanner.UpdateTrivyDB(updateCtx); err != nil {
p.deps.Logger.Error(fmt.Sprintf("Failed to update Trivy database: %v", err))
} else {
p.deps.Logger.Info("Trivy database updated successfully")
}
}
// runDailyCleanup runs daily cleanup of old vulnerability data
func (p *SecurityPlugin) runDailyCleanup(ctx context.Context, config *vulnerability.Config) {
ticker := time.NewTicker(24 * time.Hour)
defer ticker.Stop()
// Calculate time until 3 AM
now := time.Now()
next3AM := time.Date(now.Year(), now.Month(), now.Day(), 3, 0, 0, 0, now.Location())
if now.After(next3AM) {
next3AM = next3AM.Add(24 * time.Hour)
}
initialDelay := time.Until(next3AM)
p.deps.Logger.Info(fmt.Sprintf("Vulnerability cleanup scheduled for %s (in %v)", next3AM.Format("2006-01-02 15:04:05"), initialDelay))
// Wait for initial delay
select {
case <-time.After(initialDelay):
p.cleanupOldScans(ctx, config)
case <-ctx.Done():
return
}
// Then run every 24 hours
for {
select {
case <-ticker.C:
p.cleanupOldScans(ctx, config)
case <-ctx.Done():
return
}
}
}
// cleanupOldScans cleans up old vulnerability scan data
func (p *SecurityPlugin) cleanupOldScans(ctx context.Context, config *vulnerability.Config) {
p.deps.Logger.Info("Running daily vulnerability cleanup...")
if p.db == nil {
p.deps.Logger.Error("Database not initialized")
return
}
// Use the cleanup method from storage.DB
retentionDays := config.GetRetentionDays()
detailedRetentionDays := config.GetDetailedRetentionDays()
if err := p.db.CleanupOldVulnerabilityData(retentionDays, detailedRetentionDays); err != nil {
p.deps.Logger.Error(fmt.Sprintf("Vulnerability cleanup failed: %v", err))
} else {
p.deps.Logger.Info("Vulnerability cleanup completed successfully")
}
}

View File

@@ -0,0 +1,10 @@
package security
import "github.com/container-census/container-census/internal/plugins"
// Register registers the security plugin with the plugin manager
func Register(manager *plugins.Manager) {
manager.RegisterBuiltIn("security", func() plugins.Plugin {
return NewSecurityPlugin()
})
}

View File

@@ -533,6 +533,12 @@ func (s *scopedPluginDB) SetSetting(key string, value string) error {
return s.db.SetPluginSetting(s.pluginID, key, value)
}
// GetRawDB returns the underlying storage.DB for built-in plugins that need full database access
// This should only be used by trusted built-in plugins like the security plugin
func (s *scopedPluginDB) GetRawDB() *storage.DB {
return s.db
}
func (s *scopedPluginDB) GetAllSettings() (map[string]string, error) {
return s.db.GetAllPluginSettings(s.pluginID)
}

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"log"
"sort"
"strings"
"time"
"github.com/container-census/container-census/internal/models"
@@ -2454,6 +2455,30 @@ func (db *DB) SetPreference(key, value string) error {
return nil
}
// DeletePreference removes one or more user preferences
func (db *DB) DeletePreference(keys ...string) error {
if len(keys) == 0 {
return nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(keys))
args := make([]interface{}, len(keys))
for i, key := range keys {
placeholders[i] = "?"
args[i] = key
}
query := fmt.Sprintf(`DELETE FROM user_preferences WHERE key IN (%s)`,
strings.Join(placeholders, ","))
_, err := db.conn.Exec(query, args...)
if err != nil {
return fmt.Errorf("failed to delete preferences: %w", err)
}
return nil
}
// GetAllPreferences retrieves all user preferences as a map
func (db *DB) GetAllPreferences() (map[string]string, error) {
rows, err := db.conn.Query(`SELECT key, value FROM user_preferences`)

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
@@ -16,10 +17,6 @@ import (
"github.com/google/uuid"
)
const (
installationIDFile = "./data/.installation_id"
)
// Collector gathers anonymous telemetry data
type Collector struct {
db *storage.DB
@@ -271,6 +268,12 @@ func (c *Collector) CollectReport(ctx context.Context, agentStats map[string]*mo
// getOrCreateInstallationID gets or creates a unique installation ID
func getOrCreateInstallationID() (string, error) {
// Installation ID is stored in /app/data (container) or ./data (local dev)
installationIDFile := "/app/data/.installation_id"
if _, err := os.Stat("/app/data"); os.IsNotExist(err) {
installationIDFile = "./data/.installation_id"
}
// Try to read existing ID
data, err := os.ReadFile(installationIDFile)
if err == nil {
@@ -283,6 +286,9 @@ func getOrCreateInstallationID() (string, error) {
// Generate new UUID
newID := uuid.New().String()
// Ensure directory exists
os.MkdirAll(filepath.Dir(installationIDFile), 0755)
// Try to save it
if err := os.WriteFile(installationIDFile, []byte(newID), 0644); err != nil {
log.Printf("Warning: failed to save installation ID: %v", err)

59
scripts/run-collector-local.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
# Script to build and run telemetry collector locally for testing
# Uses port 8889 to avoid conflicts with production collector on 8081
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${YELLOW}Building Telemetry Collector...${NC}"
# Build the collector
cd "$(dirname "$0")/.."
CGO_ENABLED=0 go build -o /tmp/telemetry-collector ./cmd/telemetry-collector
if [ $? -eq 0 ]; then
echo -e "${GREEN}Telemetry Collector built successfully!${NC}"
ls -lh /tmp/telemetry-collector
else
echo -e "${RED}Build failed!${NC}"
exit 1
fi
echo ""
echo -e "${YELLOW}Starting Telemetry Collector on http://localhost:8889${NC}"
echo ""
# Set environment variables for local testing
export PORT=8889
export DATABASE_URL="postgres://census:census@localhost:5432/telemetry?sslmode=disable"
export COLLECTOR_AUTH_ENABLED=false
# Check if PostgreSQL is available
if ! pg_isready -h localhost -p 5432 > /dev/null 2>&1; then
echo -e "${RED}WARNING: PostgreSQL does not appear to be running on localhost:5432${NC}"
echo -e "${YELLOW}The collector will fail to start without a database connection.${NC}"
echo ""
echo "To start PostgreSQL, you can use docker-compose:"
echo " cd /opt/docker-compose/census-server && docker-compose up -d postgres"
echo ""
read -p "Continue anyway? (y/N) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
echo "Configuration:"
echo " PORT: $PORT"
echo " DATABASE_URL: $DATABASE_URL"
echo " AUTH_ENABLED: $COLLECTOR_AUTH_ENABLED"
echo ""
# Run the collector
exec /tmp/telemetry-collector

View File

@@ -166,4 +166,5 @@ SESSION_SECRET=${SESSION_SECRET} \
DATABASE_PATH=/opt/docker-compose/census-server/census/server/${DB_FILE} \
TRIVY_CACHE_DIR=/tmp/trivy-cache \
WEB_DIR=${WEB_DIR} \
TELEMETRY_ENDPOINT_URL=http://100.91.119.113:8081/api/ingest \
/tmp/census-server

View File

@@ -48,6 +48,23 @@ if [ -d "$GRAPH_PLUGIN_DIR/src" ]; then
echo -e "${GREEN}Graph Plugin frontend built successfully!${NC}"
fi
# Build Security Plugin Frontend
SECURITY_PLUGIN_DIR="$PROJECT_ROOT/internal/plugins/builtin/security/frontend"
if [ -d "$SECURITY_PLUGIN_DIR/src" ]; then
echo -e "${YELLOW}Building Security Plugin frontend...${NC}"
# Check if node_modules exists, if not install dependencies
if [ ! -d "$SECURITY_PLUGIN_DIR/node_modules" ]; then
echo "Installing Security Plugin npm dependencies..."
(cd "$SECURITY_PLUGIN_DIR" && npm install)
fi
# Build the webpack bundle
(cd "$SECURITY_PLUGIN_DIR" && npm run build)
echo -e "${GREEN}Security Plugin frontend built successfully!${NC}"
fi
# Build Go server
echo -e "${YELLOW}Building Go server...${NC}"
cd "$PROJECT_ROOT"

View File

@@ -48,3 +48,514 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--text-tertiary);
}
/* Button styles */
.btn {
padding: 0.625rem 1.25rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
text-decoration: none;
white-space: nowrap;
}
.btn:active {
transform: translateY(1px);
}
.btn-primary {
background: linear-gradient(135deg, #7c3aed 0%, #a855f7 50%, #c026d3 100%);
color: white;
}
.btn-primary:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.btn-secondary {
background-color: #3b82f6;
color: white;
}
.btn-secondary:hover {
background-color: #60a5fa;
transform: translateY(-1px);
}
/* Input styles */
.search-input {
flex: 1;
min-width: 250px;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.875rem;
background-color: var(--bg-secondary);
transition: all 0.2s;
color: var(--text-primary);
}
.search-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.search-input::placeholder {
color: var(--text-tertiary);
}
.filter-select {
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.875rem;
background-color: var(--bg-secondary);
cursor: pointer;
transition: all 0.2s;
color: var(--text-primary);
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Security section styles */
.security-section-modern {
padding: 20px;
max-width: 1600px;
margin: 0 auto;
}
.security-header-modern {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid var(--border);
}
.security-title-group h2 {
margin: 0 0 8px 0;
font-size: 28px;
color: var(--text-primary);
}
.security-subtitle {
margin: 0;
font-size: 14px;
color: var(--text-secondary);
font-weight: normal;
}
.security-actions {
display: flex;
gap: 12px;
}
.security-summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.security-summary-card-modern {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.security-summary-card-modern:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.security-summary-card-modern .card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.security-summary-card-modern .card-icon {
font-size: 24px;
}
.security-summary-card-modern .card-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.security-summary-card-modern .card-value {
font-size: 36px;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
margin-bottom: 8px;
}
.security-summary-card-modern .card-footer {
font-size: 13px;
color: var(--text-tertiary);
}
.security-summary-card-modern.critical-card .card-value {
color: #e53935;
}
.security-summary-card-modern.high-card .card-value {
color: #ff9800;
}
.security-queue-status {
display: flex;
align-items: center;
gap: 15px;
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 8px;
padding: 15px 20px;
margin-bottom: 30px;
}
.queue-status-icon {
font-size: 24px;
}
.queue-status-content {
flex: 1;
font-size: 14px;
color: #856404;
}
.security-charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.security-chart-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.chart-card-header {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid var(--border);
}
.chart-card-header h3 {
margin: 0 0 5px 0;
font-size: 18px;
color: var(--text-primary);
}
.chart-subtitle {
margin: 0;
font-size: 13px;
color: var(--text-tertiary);
}
.security-chart-container {
position: relative;
height: 300px;
}
.security-table-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.security-table-header-modern {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-tertiary);
}
.table-title-group {
display: flex;
align-items: center;
gap: 12px;
}
.table-title-group h3 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
}
.scan-count {
background: var(--border);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.security-filters-modern {
display: flex;
gap: 12px;
align-items: center;
}
.security-table-modern {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.security-table-modern thead {
background: var(--bg-tertiary);
}
.security-table-modern thead th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
border-bottom: 2px solid var(--border);
}
.security-table-modern tbody tr {
border-bottom: 1px solid var(--border);
transition: background-color 0.2s ease;
}
.security-table-modern tbody tr:hover {
background: var(--bg-tertiary);
}
.security-table-modern tbody td {
padding: 14px 16px;
font-size: 14px;
color: var(--text-primary);
}
.security-table-modern tbody td.loading {
text-align: center;
color: var(--text-tertiary);
padding: 40px;
}
.security-table-modern tbody td:first-child {
font-weight: 500;
}
/* Fixed column widths */
.security-table-modern th:nth-child(1),
.security-table-modern td:nth-child(1) { width: 30%; overflow: hidden; text-overflow: ellipsis; }
.security-table-modern th:nth-child(2),
.security-table-modern td:nth-child(2) { width: 10%; text-align: center; }
.security-table-modern th:nth-child(3),
.security-table-modern td:nth-child(3) { width: 6%; text-align: center; }
.security-table-modern th:nth-child(4),
.security-table-modern td:nth-child(4) { width: 6%; text-align: center; }
.security-table-modern th:nth-child(5),
.security-table-modern td:nth-child(5) { width: 6%; text-align: center; }
.security-table-modern th:nth-child(6),
.security-table-modern td:nth-child(6) { width: 6%; text-align: center; }
.security-table-modern th:nth-child(7),
.security-table-modern td:nth-child(7) { width: 6%; text-align: center; }
.security-table-modern th:nth-child(8),
.security-table-modern td:nth-child(8) { width: 12%; }
.security-table-modern th:nth-child(9),
.security-table-modern td:nth-child(9) {
width: 200px;
min-width: 200px;
max-width: 200px;
white-space: nowrap;
text-align: center;
}
.security-table-modern tbody td:last-child .btn {
margin: 0 2px;
padding: 4px 8px;
font-size: 12px;
}
/* Responsive adjustments */
@media (max-width: 1200px) {
.security-charts-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.security-header-modern {
flex-direction: column;
gap: 15px;
}
.security-actions {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.security-summary-grid {
grid-template-columns: 1fr;
}
.security-filters-modern {
flex-direction: column;
width: 100%;
}
.security-filters-modern .filter-select,
.security-filters-modern .search-input {
width: 100%;
}
}
/* Modal Overlay for Security Plugin */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
backdrop-filter: blur(2px);
}
.modal-content {
background: var(--bg-secondary);
border-radius: 12px;
width: 90%;
max-width: 900px;
max-height: 90vh;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
border: 1px solid var(--border);
}
.modal-header {
padding: 20px 25px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-tertiary);
}
.modal-header h2,
.modal-header h3 {
margin: 0;
color: var(--text-primary);
max-width: calc(100% - 50px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-close {
background: none;
border: none;
font-size: 32px;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
width: 36px;
height: 36px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
line-height: 1;
}
.modal-close:hover {
background: var(--bg-primary);
color: var(--text-primary);
}
.modal-body {
padding: 25px;
overflow-y: auto;
flex: 1;
color: var(--text-primary);
}
.modal-footer {
padding: 15px 25px;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: flex-end;
background: var(--bg-tertiary);
}
/* Dark theme adjustments for modal content */
.modal-content .scan-metadata {
background: var(--bg-primary);
color: var(--text-primary);
}
.modal-content .vulnerability-table thead {
background: var(--bg-tertiary);
}
.modal-content .vulnerability-table th {
color: var(--text-secondary);
}
.modal-content .vulnerability-table td {
border-color: var(--border);
color: var(--text-primary);
}
.modal-content .vulnerability-table tbody tr:hover {
background: var(--bg-tertiary);
}
.modal-content .vulnerability-filter input,
.modal-content .vulnerability-filter select {
background: var(--bg-primary);
border-color: var(--border);
color: var(--text-primary);
}

View File

@@ -64,13 +64,14 @@ export default function PluginPageClient() {
}
};
// Call the plugin's init function
const initFn = (window as any).initGraphVisualizer;
// Call the plugin's init function (use init_func from tab metadata)
const initFuncName = matchingTab.init_func || 'initGraphVisualizer';
const initFn = (window as any)[initFuncName];
if (typeof initFn === 'function') {
console.log('[PluginPage] Calling plugin init function');
console.log(`[PluginPage] Calling plugin init function: ${initFuncName}`);
initFn(container, sdk);
} else {
console.error('[PluginPage] Plugin init function not found');
console.error(`[PluginPage] Plugin init function not found: ${initFuncName}`);
setError('Plugin initialization function not found');
}
};

View File

@@ -4,9 +4,11 @@ import PluginPageClient from './PluginPageClient';
export async function generateStaticParams() {
// For static export, we need to pre-define known plugin routes
// External plugins installed at runtime won't have pages pre-generated
// Note: npm has its own static page at /integrations/npm/page.tsx
return [
{ pluginId: 'graph' },
{ pluginId: 'graph-visualizer' },
{ pluginId: 'security' },
];
}

View File

@@ -271,13 +271,19 @@ export default function NotificationsPage() {
getNotificationSilences(),
getNotificationStatus(),
]);
setLogs(logsData);
setChannels(channelsData);
setRules(rulesData);
setSilences(silencesData);
setStatus(statusData);
setLogs(logsData || []);
setChannels(channelsData || []);
setRules(rulesData || []);
setSilences(silencesData || []);
setStatus(statusData || { unread_count: 0 });
} catch (error) {
console.error('Failed to load notifications:', error);
// Ensure we still have valid empty arrays on error
setLogs([]);
setChannels([]);
setRules([]);
setSilences([]);
setStatus({ unread_count: 0 });
} finally {
setLoading(false);
}
@@ -290,18 +296,32 @@ export default function NotificationsPage() {
}, []);
const handleMarkRead = async (id: number) => {
await markNotificationRead(id);
await loadData();
try {
await markNotificationRead(id);
await loadData();
} catch (error) {
console.error('Failed to mark notification as read:', error);
}
};
const handleMarkAllRead = async () => {
await markAllNotificationsRead();
await loadData();
try {
await markAllNotificationsRead();
await loadData();
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
alert('Failed to mark all notifications as read. Please try again.');
}
};
const handleClearOld = async () => {
await clearOldNotifications();
await loadData();
try {
await clearOldNotifications();
await loadData();
} catch (error) {
console.error('Failed to clear old notifications:', error);
alert('Failed to clear old notifications. Please try again.');
}
};
const handleSaveChannel = async (channelData: Partial<NotificationChannel>) => {

View File

@@ -1,8 +1,8 @@
'use client';
import { useEffect, useState } from 'react';
import { getHealth } from '@/lib/api';
import type { HealthStatus } from '@/types';
import { getHealth, checkVersion, clearDismissedVersion } from '@/lib/api';
import type { HealthStatus, VersionCheckResponse } from '@/types';
interface Settings {
scanner: {
@@ -67,6 +67,7 @@ export default function SettingsPage() {
const [saving, setSaving] = useState<string | null>(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [versionInfo, setVersionInfo] = useState<VersionCheckResponse | null>(null);
const loadData = async () => {
try {
@@ -90,11 +91,22 @@ export default function SettingsPage() {
const handleCheckUpdates = async () => {
setCheckingUpdates(true);
try {
// Clear any previous dismissals when manually checking
await clearDismissedVersion();
// Force fresh check via telemetry collector
const versionData = await checkVersion();
setVersionInfo(versionData);
// Refresh health status to show in UI
const healthData = await getHealth();
setHealth(healthData);
// Show modal with results
setShowUpdateModal(true);
} catch (error) {
console.error('Failed to check for updates:', error);
alert('Failed to check for updates. Please try again later.');
} finally {
setCheckingUpdates(false);
}
@@ -285,11 +297,11 @@ export default function SettingsPage() {
</div>
{/* Update Modal */}
{showUpdateModal && (
{showUpdateModal && versionInfo && (
<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-md p-6">
<h2 className="text-xl font-bold mb-4">Software Update</h2>
{health?.update_available ? (
{versionInfo.update_available ? (
<>
<p className="text-[var(--text-secondary)] mb-4">
A new version of Container Census is available!
@@ -297,16 +309,16 @@ export default function SettingsPage() {
<div className="space-y-2 mb-6">
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Current Version:</span>
<span className="font-medium">v{health.version}</span>
<span className="font-medium">v{versionInfo.current_version}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--text-secondary)]">Latest Version:</span>
<span className="font-medium text-[var(--success)]">v{health.latest_version}</span>
<span className="font-medium text-[var(--success)]">v{versionInfo.latest_version}</span>
</div>
</div>
<div className="flex gap-3">
<a
href={health.release_url}
href={versionInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="flex-1 px-4 py-2 text-sm text-center bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] transition-colors"
@@ -324,7 +336,7 @@ export default function SettingsPage() {
) : (
<>
<p className="text-[var(--text-secondary)] mb-6">
You are running the latest version of Container Census (v{health?.version}).
You are running the latest version of Container Census (v{versionInfo.current_version}).
</p>
<button
onClick={() => setShowUpdateModal(false)}

View File

@@ -0,0 +1,148 @@
'use client';
import { useState, useEffect } from 'react';
import { checkVersion, getDismissedVersion, dismissVersion } from '@/lib/api';
import type { VersionCheckResponse } from '@/types';
export default function UpdateBanner() {
const [updateInfo, setUpdateInfo] = useState<VersionCheckResponse | null>(null);
const [showBanner, setShowBanner] = useState(false);
const [showModal, setShowModal] = useState(false);
useEffect(() => {
checkForUpdates();
// Check daily (24 hours)
const interval = setInterval(checkForUpdates, 24 * 60 * 60 * 1000);
return () => clearInterval(interval);
}, []);
const checkForUpdates = async () => {
try {
const [versionInfo, dismissedPref] = await Promise.all([
checkVersion(),
getDismissedVersion()
]);
setUpdateInfo(versionInfo);
// Show banner if update available and not dismissed
if (versionInfo.update_available) {
const shouldShow = shouldShowUpdate(
versionInfo.latest_version,
dismissedPref.dismissed_version,
dismissedPref.dismiss_until_major
);
setShowBanner(shouldShow);
}
} catch (error) {
console.error('Version check failed:', error);
// Silently fail - don't show banner if collector unreachable
}
};
const shouldShowUpdate = (
latestVersion: string,
dismissedVersion: string | null,
dismissUntilMajor: boolean
): boolean => {
if (!dismissedVersion) return true;
if (dismissUntilMajor) {
// Only show if major version changed
const latestMajor = parseInt(latestVersion.split('.')[0]);
const dismissedMajor = parseInt(dismissedVersion.split('.')[0]);
return latestMajor > dismissedMajor;
} else {
// Show if any newer version available
return latestVersion !== dismissedVersion;
}
};
const handleDismiss = async (dismissUntilMajor: boolean) => {
if (!updateInfo) return;
try {
await dismissVersion(updateInfo.latest_version, dismissUntilMajor);
setShowBanner(false);
setShowModal(false);
} catch (error) {
console.error('Failed to dismiss version:', error);
}
};
if (!showBanner || !updateInfo) return null;
return (
<>
{/* Banner */}
<div className="bg-[var(--accent)] text-white px-4 py-3 flex items-center justify-between sticky top-0 z-40">
<div className="flex items-center gap-3">
<span className="text-2xl">🎉</span>
<div>
<span className="font-medium">
Container Census v{updateInfo.latest_version} is available!
</span>
<span className="text-white/80 ml-2">
You&apos;re on v{updateInfo.current_version}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<a
href={updateInfo.release_url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-1.5 bg-white text-[var(--accent)] rounded hover:bg-white/90 transition-colors text-sm font-medium"
>
View Release
</a>
<button
onClick={() => setShowModal(true)}
className="px-4 py-1.5 bg-white/20 hover:bg-white/30 rounded transition-colors text-sm"
>
Dismiss
</button>
</div>
</div>
{/* Dismissal Modal */}
{showModal && (
<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-md p-6">
<h2 className="text-xl font-bold mb-4">Dismiss Update Notification</h2>
<p className="text-[var(--text-secondary)] mb-6">
How would you like to dismiss this update notification?
</p>
<div className="space-y-3">
<button
onClick={() => handleDismiss(false)}
className="w-full px-4 py-3 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded hover:bg-[var(--bg-primary)] transition-colors text-left"
>
<div className="font-medium">Dismiss v{updateInfo.latest_version}</div>
<div className="text-sm text-[var(--text-secondary)]">
Hide this version, show me the next one
</div>
</button>
<button
onClick={() => handleDismiss(true)}
className="w-full px-4 py-3 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded hover:bg-[var(--bg-primary)] transition-colors text-left"
>
<div className="font-medium">Dismiss until next major release</div>
<div className="text-sm text-[var(--text-secondary)]">
Only notify me for major version updates (e.g., v1.0.0 v2.0.0)
</div>
</button>
</div>
<button
onClick={() => setShowModal(false)}
className="w-full mt-4 px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</>
);
}

View File

@@ -3,6 +3,7 @@
import { ReactNode, useState } from 'react';
import Sidebar from './Sidebar';
import Header from './Header';
import UpdateBanner from '../UpdateBanner';
import { triggerScan, submitTelemetry } from '@/lib/api';
interface AppLayoutProps {
@@ -25,6 +26,7 @@ export default function AppLayout({ children }: AppLayoutProps) {
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header onScan={handleScan} onTelemetry={handleTelemetry} />
<UpdateBanner />
<main className="flex-1 overflow-auto p-6">
{children}
</main>

View File

@@ -17,7 +17,6 @@ const mainNavItems: NavItem[] = [
{ href: '/', label: 'Dashboard', icon: '📊' },
{ href: '/containers', label: 'Containers', icon: '📦' },
{ href: '/hosts', label: 'Hosts', icon: '🖥️' },
{ href: '/security', label: 'Security', icon: '🛡️' },
];
const bottomNavItems: NavItem[] = [
@@ -108,41 +107,39 @@ export default function Sidebar() {
))}
</div>
{/* Integrations section - always expanded */}
{pluginTabs.length > 0 && (
<div className="mt-4">
<div className="px-3 py-2 text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider">
Integrations
</div>
<div className="space-y-1">
{pluginTabs.map((tab) => (
<Link
key={tab.id}
href={`/integrations/${tab.id}`}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === `/integrations/${tab.id}`
? 'bg-[var(--accent)] text-white'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
<span className="text-lg">{tab.icon}</span>
<span>{tab.label}</span>
</Link>
))}
{/* Integrations section - always show Manage Plugins */}
<div className="mt-4">
<div className="px-3 py-2 text-xs font-semibold text-[var(--text-tertiary)] uppercase tracking-wider">
Integrations
</div>
<div className="space-y-1">
{pluginTabs.map((tab) => (
<Link
href="/integrations"
key={tab.id}
href={`/integrations/${tab.id}`}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === '/integrations'
pathname === `/integrations/${tab.id}`
? 'bg-[var(--accent)] text-white'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
<span className="text-lg"></span>
<span>Manage Plugins</span>
<span className="text-lg">{tab.icon}</span>
<span>{tab.label}</span>
</Link>
</div>
))}
<Link
href="/integrations"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === '/integrations'
? 'bg-[var(--accent)] text-white'
: 'text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] hover:text-[var(--text-primary)]'
}`}
>
<span className="text-lg"></span>
<span>Manage Plugins</span>
</Link>
</div>
)}
</div>
{/* Bottom navigation */}
<div className="mt-4 pt-4 border-t border-[var(--border)] space-y-1">

View File

@@ -71,25 +71,25 @@ export const scanHost = (id: number) =>
// Images
export const getImages = () => fetchApi<import('@/types').Image[]>('/images');
// Vulnerabilities
// Vulnerabilities (Security Plugin)
export const getVulnerabilitySummary = () =>
fetchApi<import('@/types').VulnerabilitySummary>('/vulnerabilities/summary');
fetchApi<import('@/types').VulnerabilitySummary>('/p/security/summary');
export const getVulnerabilityScans = (limit?: number) =>
fetchApi<import('@/types').VulnerabilityScan[]>(`/vulnerabilities/scans${limit ? `?limit=${limit}` : ''}`);
fetchApi<import('@/types').VulnerabilityScan[]>(`/p/security/scans${limit ? `?limit=${limit}` : ''}`);
export const getVulnerabilityDetails = (imageId: string) =>
fetchApi<{ scan: import('@/types').VulnerabilityScan; vulnerabilities: import('@/types').Vulnerability[] }>(
`/vulnerabilities/image/${encodeURIComponent(imageId)}`
`/p/security/image/${encodeURIComponent(imageId)}`
);
export const scanImage = (imageId: string) =>
fetchApi<void>(`/vulnerabilities/scan/${encodeURIComponent(imageId)}`, { method: 'POST' });
fetchApi<void>(`/p/security/scan/${encodeURIComponent(imageId)}`, { method: 'POST' });
export const scanAllImages = () =>
fetchApi<void>('/vulnerabilities/scan-all', { method: 'POST' });
fetchApi<void>('/p/security/scan-all', { method: 'POST' });
export const updateVulnerabilityDb = () =>
fetchApi<void>('/vulnerabilities/update-db', { method: 'POST' });
fetchApi<void>('/p/security/update-db', { method: 'POST' });
export const getVulnerabilitySettings = () =>
fetchApi<Record<string, string>>('/vulnerabilities/settings');
fetchApi<Record<string, string>>('/p/security/settings');
export const updateVulnerabilitySettings = (settings: Record<string, string>) =>
fetchApi<void>('/vulnerabilities/settings', { method: 'PUT', body: JSON.stringify(settings) });
fetchApi<void>('/p/security/settings', { method: 'PUT', body: JSON.stringify(settings) });
// Notifications
export const getNotificationChannels = () =>
@@ -209,3 +209,58 @@ export const getContainerLifecycleSummaries = (limit: number = 200, hostId?: num
if (hostId) params.append('host_id', hostId.toString());
return fetchApi<import('@/types').ContainerLifecycleSummary[]>(`/containers/lifecycle?${params}`);
};
// Version checking via telemetry collector
export const checkVersion = async (): Promise<import('@/types').VersionCheckResponse> => {
// Get installation ID from health endpoint
const health = await fetchApi<import('@/types').HealthStatus>('/health');
// Get installation ID from localStorage or generate
let installationId: string = localStorage.getItem('installation_id') || '';
if (!installationId) {
// Try to fetch from server
const response = await fetch('/api/installation-id', {
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
installationId = data.installation_id || 'browser-' + Math.random().toString(36).substring(2);
} else {
// Fallback: use a browser-specific ID
installationId = 'browser-' + Math.random().toString(36).substring(2);
}
localStorage.setItem('installation_id', installationId);
}
// Call telemetry collector (public endpoint)
const collectorUrl = process.env.NEXT_PUBLIC_TELEMETRY_COLLECTOR_URL ||
'https://telemetry.container-census.com';
const response = await fetch(`${collectorUrl}/api/version/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
installation_id: installationId,
current_version: health.version
})
});
if (!response.ok) {
throw new Error('Version check failed');
}
return response.json();
};
// Dismissed version preferences
export const getDismissedVersion = () =>
fetchApi<import('@/types').DismissedVersionPreference>('/preferences/dismissed-version');
export const dismissVersion = (version: string, dismissUntilMajor: boolean = false) =>
fetchApi('/preferences/dismiss-version', {
method: 'POST',
body: JSON.stringify({ version, dismiss_until_major: dismissUntilMajor })
});
export const clearDismissedVersion = () =>
fetchApi('/preferences/dismissed-version', { method: 'DELETE' });

View File

@@ -236,6 +236,20 @@ export interface HealthStatus {
release_url?: string;
}
export interface VersionCheckResponse {
current_version: string;
latest_version: string;
update_available: boolean;
release_url: string;
checked_at: string;
error?: string;
}
export interface DismissedVersionPreference {
dismissed_version: string | null;
dismiss_until_major: boolean;
}
// Dashboard stats
export interface DashboardStats {
total_hosts: number;

View File

@@ -1,5 +1,6 @@
let topImagesChart = null;
let growthChart = null;
let activeInstallsChart = null;
let registriesChart = null;
let versionsChart = null;
let scanIntervalsChart = null;
@@ -262,6 +263,103 @@ function initCharts() {
}
});
// Active Installations Chart
const activeInstallsCtx = document.getElementById('activeInstallsChart').getContext('2d');
activeInstallsChart = new Chart(activeInstallsCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Active Installations',
data: [],
borderColor: gradientColors.teal.solid,
backgroundColor: function(context) {
const chart = context.chart;
const {ctx, chartArea} = chart;
if (!chartArea) return gradientColors.teal.start;
const gradient = ctx.createLinearGradient(0, chartArea.bottom, 0, chartArea.top);
gradient.addColorStop(0, gradientColors.teal.end);
gradient.addColorStop(1, gradientColors.teal.start);
return gradient;
},
tension: 0.4,
fill: true,
pointRadius: 4,
pointHoverRadius: 7,
pointBackgroundColor: gradientColors.teal.solid,
pointBorderColor: '#fff',
pointBorderWidth: 2,
pointHoverBorderWidth: 3,
borderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false
},
animation: {
duration: 2000,
easing: 'easeInOutQuart'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
padding: 12,
titleFont: {
size: 14,
weight: 'bold'
},
bodyFont: {
size: 13
},
callbacks: {
label: function(context) {
return ' ' + context.parsed.y.toLocaleString() + ' active installations';
}
}
}
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Active Installations',
font: {
size: 14,
weight: 'bold'
}
},
ticks: {
stepSize: 1,
callback: function(value) {
return value.toLocaleString();
}
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
},
x: {
grid: {
display: false
},
ticks: {
font: {
size: 11
}
}
}
}
}
});
// Registries Chart (Doughnut)
const registriesCtx = document.getElementById('registriesChart').getContext('2d');
registriesChart = new Chart(registriesCtx, {
@@ -735,6 +833,9 @@ async function loadData() {
// Load growth data
await loadGrowth(days);
// Load active installations data
await loadActiveInstalls();
// Load new charts
await loadRegistries(days);
await loadVersions();
@@ -754,7 +855,8 @@ async function loadSummary() {
const data = await response.json();
document.getElementById('totalInstallations').textContent = formatNumber(data.installations);
// Use active_installs_30d if available, fallback to installations
document.getElementById('totalInstallations').textContent = formatNumber(data.active_installs_30d || data.installations);
document.getElementById('totalSubmissions').textContent = formatNumber(data.total_submissions);
document.getElementById('totalContainers').textContent = formatNumber(data.total_containers);
document.getElementById('avgContainers').textContent = data.avg_containers_per_install ? data.avg_containers_per_install.toFixed(1) : '-';
@@ -803,6 +905,22 @@ async function loadGrowth(days) {
}
}
async function loadActiveInstalls() {
try {
const response = await fetch('/api/stats/active-installs');
if (!response.ok) throw new Error('Failed to fetch active installations');
const data = await response.json();
// Update chart with daily active installs data
activeInstallsChart.data.labels = data.daily_active_installs.map(item => formatDate(item.date));
activeInstallsChart.data.datasets[0].data = data.daily_active_installs.map(item => item.count);
activeInstallsChart.update();
} catch (error) {
console.error('Failed to load active installations:', error);
}
}
async function loadRegistries(days) {
try {
const response = await fetch(`/api/stats/registries?days=${days}`);

View File

@@ -29,7 +29,7 @@
<div class="summary-cards">
<div class="card">
<div class="card-value" id="totalInstallations">-</div>
<div class="card-label">Active Installations</div>
<div class="card-label">Active Installations (30d)</div>
</div>
<div class="card">
<div class="card-value" id="totalSubmissions">-</div>
@@ -88,6 +88,12 @@
<canvas id="growthChart"></canvas>
</div>
<div class="chart-container">
<h2>Active Installations (Daily)</h2>
<p class="chart-subtitle">Unique installations checking for updates each day</p>
<canvas id="activeInstallsChart"></canvas>
</div>
<div class="chart-row">
<div class="chart-container half-width">
<h2>Registry Distribution</h2>