mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2025-12-21 14:09:46 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}) {
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
1
internal/plugins/builtin/security/frontend/bundle.js
Normal file
1
internal/plugins/builtin/security/frontend/bundle.js
Normal file
File diff suppressed because one or more lines are too long
1626
internal/plugins/builtin/security/frontend/package-lock.json
generated
Normal file
1626
internal/plugins/builtin/security/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
internal/plugins/builtin/security/frontend/package.json
Normal file
19
internal/plugins/builtin/security/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1065
internal/plugins/builtin/security/frontend/src/index.js
Normal file
1065
internal/plugins/builtin/security/frontend/src/index.js
Normal file
File diff suppressed because it is too large
Load Diff
588
internal/plugins/builtin/security/frontend/src/styles.css
Normal file
588
internal/plugins/builtin/security/frontend/src/styles.css
Normal 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);
|
||||
}
|
||||
25
internal/plugins/builtin/security/frontend/webpack.config.js
Normal file
25
internal/plugins/builtin/security/frontend/webpack.config.js
Normal 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,
|
||||
},
|
||||
};
|
||||
335
internal/plugins/builtin/security/handlers.go
Normal file
335
internal/plugins/builtin/security/handlers.go
Normal 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",
|
||||
})
|
||||
}
|
||||
353
internal/plugins/builtin/security/plugin.go
Normal file
353
internal/plugins/builtin/security/plugin.go
Normal 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")
|
||||
}
|
||||
}
|
||||
10
internal/plugins/builtin/security/register.go
Normal file
10
internal/plugins/builtin/security/register.go
Normal 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()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
59
scripts/run-collector-local.sh
Executable 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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
148
web-next/src/components/UpdateBanner.tsx
Normal file
148
web-next/src/components/UpdateBanner.tsx
Normal 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'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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user