diff --git a/.version b/.version
index 27f9cd3..53adb84 100644
--- a/.version
+++ b/.version
@@ -1 +1 @@
-1.8.0
+1.8.2
diff --git a/cmd/server/main.go b/cmd/server/main.go
index adca330..2bd0902 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -18,6 +18,8 @@ import (
"github.com/container-census/container-census/internal/migration"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications"
+ "github.com/container-census/container-census/internal/plugins"
+ "github.com/container-census/container-census/internal/plugins/builtin/npm"
"github.com/container-census/container-census/internal/registry"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
@@ -236,6 +238,25 @@ func main() {
// Store API server reference for hot-reload
services.apiServer = apiServer
+ // Initialize plugin manager
+ containerProvider := &containerProviderImpl{db: db}
+ hostProvider := &hostProviderImpl{db: db}
+ pluginManager := plugins.NewManager(db, containerProvider, hostProvider)
+ pluginManager.SetRouter(apiServer.GetRouter())
+ apiServer.SetPluginManager(pluginManager)
+
+ // Register built-in plugins
+ npm.Register(pluginManager)
+
+ // Load and start plugins
+ if err := pluginManager.LoadBuiltInPlugins(context.Background()); err != nil {
+ log.Printf("Warning: Failed to load built-in plugins: %v", err)
+ }
+ if err := pluginManager.Start(context.Background()); err != nil {
+ log.Printf("Warning: Failed to start plugins: %v", err)
+ }
+ log.Println("Plugin manager initialized")
+
server := &http.Server{
Addr: addr,
Handler: apiServer.Router(),
@@ -916,3 +937,34 @@ func runImageUpdateChecker(ctx context.Context, db *storage.DB, scan *scanner.Sc
}
}
}
+
+// containerProviderImpl implements plugins.ContainerProvider
+type containerProviderImpl struct {
+ db *storage.DB
+}
+
+func (p *containerProviderImpl) GetContainers() []models.Container {
+ containers, err := p.db.GetLatestContainers()
+ if err != nil {
+ log.Printf("Error getting containers for plugin: %v", err)
+ return nil
+ }
+ return containers
+}
+
+func (p *containerProviderImpl) GetContainerByID(hostID int64, containerID string) (*models.Container, error) {
+ return p.db.GetContainerByID(hostID, containerID)
+}
+
+// hostProviderImpl implements plugins.HostProvider
+type hostProviderImpl struct {
+ db *storage.DB
+}
+
+func (p *hostProviderImpl) GetHosts() ([]models.Host, error) {
+ return p.db.GetHosts()
+}
+
+func (p *hostProviderImpl) GetHostByID(id int64) (*models.Host, error) {
+ return p.db.GetHost(id)
+}
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 6c4521d..8d0541f 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -228,18 +228,34 @@ func (a *Agent) handleListContainers(w http.ResponseWriter, r *http.Request) {
// Inspect container for detailed connection info
var restartCount int
var networks []string
+ var networkDetails []models.NetworkDetail
var volumes []models.VolumeMount
var links []string
var composeProject string
+ var startedAt time.Time
containerJSON, err := a.dockerClient.ContainerInspect(ctx, c.ID)
if err == nil {
restartCount = containerJSON.RestartCount
- // Extract network connections
+ // Extract StartedAt for uptime tracking
+ if containerJSON.State != nil && containerJSON.State.StartedAt != "" {
+ if parsed, parseErr := time.Parse(time.RFC3339Nano, containerJSON.State.StartedAt); parseErr == nil {
+ startedAt = parsed
+ }
+ }
+
+ // Extract network connections with details
if containerJSON.NetworkSettings != nil && containerJSON.NetworkSettings.Networks != nil {
- for networkName := range containerJSON.NetworkSettings.Networks {
+ for networkName, networkSettings := range containerJSON.NetworkSettings.Networks {
networks = append(networks, networkName)
+ // Add detailed network info for plugin matching
+ networkDetails = append(networkDetails, models.NetworkDetail{
+ NetworkName: networkName,
+ IPAddress: networkSettings.IPAddress,
+ Gateway: networkSettings.Gateway,
+ Aliases: networkSettings.Aliases,
+ })
}
}
@@ -288,9 +304,11 @@ func (a *Agent) handleListContainers(w http.ResponseWriter, r *http.Request) {
Created: time.Unix(c.Created, 0),
ScannedAt: now,
Networks: networks,
+ NetworkDetails: networkDetails,
Volumes: volumes,
Links: links,
ComposeProject: composeProject,
+ StartedAt: startedAt,
}
result = append(result, container)
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 23213d7..164ae1e 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -15,6 +15,7 @@ import (
"github.com/container-census/container-census/internal/auth"
"github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications"
+ "github.com/container-census/container-census/internal/plugins"
"github.com/container-census/container-census/internal/registry"
"github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage"
@@ -40,6 +41,8 @@ type Server struct {
notificationService *notifications.NotificationService
vulnScanner VulnerabilityScanner
vulnScheduler VulnerabilityScheduler
+ pluginManager *plugins.Manager
+ apiRouter *mux.Router // Subrouter for /api with auth middleware
}
// TelemetryScheduler interface for submitting telemetry on demand
@@ -171,6 +174,7 @@ func (s *Server) setupRoutes() {
// Protected API routes
api := s.router.PathPrefix("/api").Subrouter()
api.Use(sessionMiddleware)
+ s.apiRouter = api // Store for plugin route mounting
// Host endpoints
api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET")
@@ -291,6 +295,9 @@ func (s *Server) setupRoutes() {
// Changelog endpoint
api.HandleFunc("/changelog", s.handleGetChangelog).Methods("GET")
+ // Plugin endpoints
+ s.setupPluginRoutes(api)
+
// Serve static files with selective authentication
// Login pages are public, everything else requires auth
// Add cache control headers for JS files to ensure updates are seen
diff --git a/internal/api/plugins.go b/internal/api/plugins.go
new file mode 100644
index 0000000..1e6a9c9
--- /dev/null
+++ b/internal/api/plugins.go
@@ -0,0 +1,282 @@
+package api
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "github.com/container-census/container-census/internal/models"
+ "github.com/container-census/container-census/internal/plugins"
+ "github.com/gorilla/mux"
+)
+
+// SetPluginManager sets the plugin manager
+func (s *Server) SetPluginManager(pm *plugins.Manager) {
+ s.pluginManager = pm
+}
+
+// GetRouter returns the API subrouter for plugin route mounting (with auth middleware)
+func (s *Server) GetRouter() *mux.Router {
+ return s.apiRouter
+}
+
+// setupPluginRoutes sets up routes for plugin management
+func (s *Server) setupPluginRoutes(api *mux.Router) {
+ // Plugin management endpoints
+ api.HandleFunc("/plugins", s.handleGetPlugins).Methods("GET")
+ api.HandleFunc("/plugins/tabs", s.handleGetPluginTabs).Methods("GET")
+ api.HandleFunc("/plugins/badges", s.handleGetPluginBadges).Methods("GET")
+ api.HandleFunc("/plugins/{id}", s.handleGetPlugin).Methods("GET")
+ api.HandleFunc("/plugins/{id}/enable", s.handleEnablePlugin).Methods("PUT")
+ api.HandleFunc("/plugins/{id}/disable", s.handleDisablePlugin).Methods("PUT")
+ api.HandleFunc("/plugins/{id}/settings", s.handleGetPluginSettings).Methods("GET")
+ api.HandleFunc("/plugins/{id}/settings", s.handleUpdatePluginSettings).Methods("PUT")
+}
+
+// handleGetPlugins returns all registered plugins
+func (s *Server) handleGetPlugins(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ infos, err := s.pluginManager.GetAllPluginInfo()
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ // Add enabled status from database
+ type PluginWithStatus struct {
+ plugins.PluginInfo
+ Enabled bool `json:"enabled"`
+ }
+
+ result := make([]PluginWithStatus, len(infos))
+ for i, info := range infos {
+ // Check if loaded (loaded means enabled)
+ _, loaded := s.pluginManager.GetPlugin(info.ID)
+ result[i] = PluginWithStatus{
+ PluginInfo: info,
+ Enabled: loaded,
+ }
+ }
+
+ respondJSON(w, http.StatusOK, result)
+}
+
+// handleGetPlugin returns a specific plugin
+func (s *Server) handleGetPlugin(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondError(w, http.StatusServiceUnavailable, "Plugin system not initialized")
+ return
+ }
+
+ vars := mux.Vars(r)
+ id := vars["id"]
+
+ plugin, ok := s.pluginManager.GetPlugin(id)
+ if !ok {
+ respondError(w, http.StatusNotFound, "Plugin not found")
+ return
+ }
+
+ info := plugin.Info()
+
+ // Get settings definition if available
+ type PluginDetails struct {
+ plugins.PluginInfo
+ Enabled bool `json:"enabled"`
+ Settings *plugins.SettingsDefinition `json:"settings,omitempty"`
+ Tab *plugins.TabDefinition `json:"tab,omitempty"`
+ }
+
+ details := PluginDetails{
+ PluginInfo: info,
+ Enabled: true, // If we got it, it's enabled
+ Settings: plugin.Settings(),
+ Tab: plugin.Tab(),
+ }
+
+ respondJSON(w, http.StatusOK, details)
+}
+
+// handleGetPluginTabs returns all plugin tabs for navigation
+func (s *Server) handleGetPluginTabs(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ tabs := s.pluginManager.GetAllTabs()
+ respondJSON(w, http.StatusOK, tabs)
+}
+
+// handleGetPluginBadges returns badges for a container from all plugins
+func (s *Server) handleGetPluginBadges(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ // Get container ID and host ID from query params
+ hostIDStr := r.URL.Query().Get("host_id")
+ containerID := r.URL.Query().Get("container_id")
+
+ if hostIDStr == "" || containerID == "" {
+ respondError(w, http.StatusBadRequest, "Missing host_id or container_id query parameter")
+ return
+ }
+
+ hostID, err := strconv.ParseInt(hostIDStr, 10, 64)
+ if err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid host_id")
+ return
+ }
+
+ // Get the container from storage
+ containers, err := s.db.GetLatestContainers()
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ var targetContainer *models.Container
+ for i := range containers {
+ if containers[i].HostID == hostID && containers[i].ID == containerID {
+ targetContainer = &containers[i]
+ break
+ }
+ }
+
+ if targetContainer == nil {
+ respondJSON(w, http.StatusOK, []interface{}{})
+ return
+ }
+
+ badges := s.pluginManager.GetBadgesForContainer(r.Context(), *targetContainer)
+ respondJSON(w, http.StatusOK, badges)
+}
+
+// handleEnablePlugin enables a plugin
+func (s *Server) handleEnablePlugin(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondError(w, http.StatusServiceUnavailable, "Plugin system not initialized")
+ return
+ }
+
+ vars := mux.Vars(r)
+ id := vars["id"]
+
+ if err := s.pluginManager.EnablePlugin(r.Context(), id); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": true,
+ "message": "Plugin enabled",
+ })
+}
+
+// handleDisablePlugin disables a plugin
+func (s *Server) handleDisablePlugin(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondError(w, http.StatusServiceUnavailable, "Plugin system not initialized")
+ return
+ }
+
+ vars := mux.Vars(r)
+ id := vars["id"]
+
+ if err := s.pluginManager.DisablePlugin(r.Context(), id); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": true,
+ "message": "Plugin disabled",
+ })
+}
+
+// handleGetPluginSettings returns settings for a plugin
+func (s *Server) handleGetPluginSettings(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondError(w, http.StatusServiceUnavailable, "Plugin system not initialized")
+ return
+ }
+
+ vars := mux.Vars(r)
+ id := vars["id"]
+
+ // Get plugin to verify it exists
+ plugin, ok := s.pluginManager.GetPlugin(id)
+ if !ok {
+ respondError(w, http.StatusNotFound, "Plugin not found")
+ return
+ }
+
+ // Get settings from database
+ settings, err := s.db.GetAllPluginSettings(id)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+
+ // Get schema for defaults
+ schema := plugin.Settings()
+
+ // Merge defaults with stored values
+ result := make(map[string]string)
+ if schema != nil {
+ for _, field := range schema.Fields {
+ if val, exists := settings[field.Key]; exists {
+ result[field.Key] = val
+ } else {
+ result[field.Key] = field.Default
+ }
+ }
+ } else {
+ result = settings
+ }
+
+ respondJSON(w, http.StatusOK, result)
+}
+
+// handleUpdatePluginSettings updates settings for a plugin
+func (s *Server) handleUpdatePluginSettings(w http.ResponseWriter, r *http.Request) {
+ if s.pluginManager == nil {
+ respondError(w, http.StatusServiceUnavailable, "Plugin system not initialized")
+ return
+ }
+
+ vars := mux.Vars(r)
+ id := vars["id"]
+
+ // Get plugin to verify it exists
+ _, ok := s.pluginManager.GetPlugin(id)
+ if !ok {
+ respondError(w, http.StatusNotFound, "Plugin not found")
+ return
+ }
+
+ var settings map[string]string
+ if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
+ respondError(w, http.StatusBadRequest, "Invalid JSON")
+ return
+ }
+
+ // Save each setting
+ for key, value := range settings {
+ if err := s.db.SetPluginSetting(id, key, value); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ }
+
+ respondJSON(w, http.StatusOK, map[string]interface{}{
+ "success": true,
+ "message": "Settings updated",
+ })
+}
diff --git a/internal/models/models.go b/internal/models/models.go
index 4a57db1..7641eb4 100644
--- a/internal/models/models.go
+++ b/internal/models/models.go
@@ -53,6 +53,20 @@ type Container struct {
// Image update tracking
UpdateAvailable bool `json:"update_available"`
LastUpdateCheck time.Time `json:"last_update_check,omitempty"`
+ // Container uptime tracking
+ StartedAt time.Time `json:"started_at,omitempty"`
+ // Network details for plugin matching (e.g., NPM integration)
+ NetworkDetails []NetworkDetail `json:"network_details,omitempty"`
+ // Plugin-provided enrichment data
+ PluginData map[string]interface{} `json:"plugin_data,omitempty"`
+}
+
+// NetworkDetail contains per-network container connection info
+type NetworkDetail struct {
+ NetworkName string `json:"network_name"`
+ IPAddress string `json:"ip_address"`
+ Gateway string `json:"gateway,omitempty"`
+ Aliases []string `json:"aliases,omitempty"`
}
// PortMapping represents a container port mapping
diff --git a/internal/plugins/builtin/npm/client.go b/internal/plugins/builtin/npm/client.go
new file mode 100644
index 0000000..f67fca4
--- /dev/null
+++ b/internal/plugins/builtin/npm/client.go
@@ -0,0 +1,212 @@
+package npm
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Client handles communication with NPM API
+type Client struct {
+ mu sync.RWMutex
+ baseURL string
+ email string
+ password string
+ token string
+ tokenExp time.Time
+ httpClient *http.Client
+}
+
+// NewClient creates a new NPM API client
+func NewClient(baseURL, email, password string) *Client {
+ return &Client{
+ baseURL: baseURL,
+ email: email,
+ password: password,
+ httpClient: &http.Client{
+ Timeout: 30 * time.Second,
+ },
+ }
+}
+
+// TokenResponse represents the NPM token API response
+type TokenResponse struct {
+ Token string `json:"token"`
+ Expires time.Time `json:"expires"`
+}
+
+// ProxyHost represents an NPM proxy host
+type ProxyHost struct {
+ ID int `json:"id"`
+ CreatedOn string `json:"created_on"`
+ ModifiedOn string `json:"modified_on"`
+ DomainNames []string `json:"domain_names"`
+ ForwardScheme string `json:"forward_scheme"`
+ ForwardHost string `json:"forward_host"`
+ ForwardPort int `json:"forward_port"`
+ CertificateID int `json:"certificate_id"`
+ SSLForced bool `json:"ssl_forced"`
+ HSTSEnabled bool `json:"hsts_enabled"`
+ HSTSSubdomains bool `json:"hsts_subdomains"`
+ HTTP2Support bool `json:"http2_support"`
+ BlockExploits bool `json:"block_exploits"`
+ CachingEnabled bool `json:"caching_enabled"`
+ AllowWebsocket bool `json:"allow_websocket_upgrade"`
+ AccessListID int `json:"access_list_id"`
+ Enabled bool `json:"enabled"`
+ Meta struct {
+ LetsencryptAgree bool `json:"letsencrypt_agree"`
+ DNSChallenge bool `json:"dns_challenge"`
+ LetsencryptEmail string `json:"letsencrypt_email"`
+ NginxOnline bool `json:"nginx_online"`
+ NginxErr string `json:"nginx_err"`
+ } `json:"meta"`
+}
+
+// authenticate gets a new token from NPM
+func (c *Client) authenticate() error {
+ payload := map[string]string{
+ "identity": c.email,
+ "secret": c.password,
+ }
+
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal auth request: %w", err)
+ }
+
+ req, err := http.NewRequest("POST", c.baseURL+"/api/tokens", bytes.NewBuffer(body))
+ if err != nil {
+ return fmt.Errorf("failed to create auth request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("auth request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("auth failed with status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var tokenResp TokenResponse
+ if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
+ return fmt.Errorf("failed to decode token response: %w", err)
+ }
+
+ c.mu.Lock()
+ c.token = tokenResp.Token
+ c.tokenExp = tokenResp.Expires
+ c.mu.Unlock()
+
+ return nil
+}
+
+// getToken returns a valid token, refreshing if necessary
+func (c *Client) getToken() (string, error) {
+ c.mu.RLock()
+ token := c.token
+ exp := c.tokenExp
+ c.mu.RUnlock()
+
+ // Refresh if token is empty or expired (with 5 minute buffer)
+ if token == "" || time.Now().Add(5*time.Minute).After(exp) {
+ if err := c.authenticate(); err != nil {
+ return "", err
+ }
+ c.mu.RLock()
+ token = c.token
+ c.mu.RUnlock()
+ }
+
+ return token, nil
+}
+
+// doRequest performs an authenticated request to NPM API
+func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) {
+ token, err := c.getToken()
+ if err != nil {
+ return nil, err
+ }
+
+ req, err := http.NewRequest(method, c.baseURL+path, body)
+ if err != nil {
+ return nil, err
+ }
+
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+
+ // If we get a 401, try to re-authenticate once
+ if resp.StatusCode == http.StatusUnauthorized {
+ resp.Body.Close()
+ if err := c.authenticate(); err != nil {
+ return nil, fmt.Errorf("re-authentication failed: %w", err)
+ }
+
+ token, _ = c.getToken()
+ req, err = http.NewRequest(method, c.baseURL+path, body)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err = c.httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ return resp, nil
+}
+
+// GetProxyHosts retrieves all proxy hosts from NPM
+func (c *Client) GetProxyHosts() ([]ProxyHost, error) {
+ resp, err := c.doRequest("GET", "/api/nginx/proxy-hosts", nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get proxy hosts: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ bodyBytes, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("get proxy hosts failed with status %d: %s", resp.StatusCode, string(bodyBytes))
+ }
+
+ var hosts []ProxyHost
+ if err := json.NewDecoder(resp.Body).Decode(&hosts); err != nil {
+ return nil, fmt.Errorf("failed to decode proxy hosts: %w", err)
+ }
+
+ return hosts, nil
+}
+
+// TestConnection tests if the NPM instance is reachable and credentials are valid
+func (c *Client) TestConnection() error {
+ _, err := c.getToken()
+ if err != nil {
+ return fmt.Errorf("authentication failed: %w", err)
+ }
+
+ // Try to get proxy hosts to verify full access
+ _, err = c.GetProxyHosts()
+ if err != nil {
+ return fmt.Errorf("failed to access proxy hosts: %w", err)
+ }
+
+ return nil
+}
diff --git a/internal/plugins/builtin/npm/plugin.go b/internal/plugins/builtin/npm/plugin.go
new file mode 100644
index 0000000..3c7b4f2
--- /dev/null
+++ b/internal/plugins/builtin/npm/plugin.go
@@ -0,0 +1,827 @@
+package npm
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "sync"
+ "time"
+
+ "github.com/container-census/container-census/internal/models"
+ "github.com/container-census/container-census/internal/plugins"
+ "github.com/gorilla/mux"
+)
+
+// Plugin implements the NPM (Nginx Proxy Manager) integration
+type Plugin struct {
+ mu sync.RWMutex
+ deps plugins.PluginDependencies
+ instances map[int64]*NPMInstance
+ proxyHosts map[int64][]ProxyHost // instanceID -> proxy hosts
+ mappings map[string][]ProxyHostMapping // containerKey -> proxy host mappings
+ stopChan chan struct{}
+ syncTicker *time.Ticker
+}
+
+// NPMInstance represents a configured NPM instance
+type NPMInstance struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ URL string `json:"url"`
+ Email string `json:"email"`
+ Password string `json:"-"` // Don't expose password in JSON
+ Enabled bool `json:"enabled"`
+ LastSync time.Time `json:"last_sync,omitempty"`
+ LastError string `json:"last_error,omitempty"`
+ client *Client
+}
+
+// ProxyHostMapping maps a container to an NPM proxy host
+type ProxyHostMapping struct {
+ InstanceID int64 `json:"instance_id"`
+ InstanceName string `json:"instance_name"`
+ ProxyHostID int `json:"proxy_host_id"`
+ DomainNames []string `json:"domain_names"`
+ SSLEnabled bool `json:"ssl_enabled"`
+ Enabled bool `json:"enabled"`
+ MatchType string `json:"match_type"` // "ip_port" or "hostname"
+}
+
+// New creates a new NPM plugin instance
+func New() plugins.Plugin {
+ return &Plugin{
+ instances: make(map[int64]*NPMInstance),
+ proxyHosts: make(map[int64][]ProxyHost),
+ mappings: make(map[string][]ProxyHostMapping),
+ stopChan: make(chan struct{}),
+ }
+}
+
+// Register registers the NPM plugin with the plugin manager
+func Register(manager *plugins.Manager) {
+ manager.RegisterBuiltIn("npm", New)
+}
+
+// Info returns plugin metadata
+func (p *Plugin) Info() plugins.PluginInfo {
+ return plugins.PluginInfo{
+ ID: "npm",
+ Name: "Nginx Proxy Manager",
+ Description: "Integration with Nginx Proxy Manager to show which containers are exposed externally",
+ Version: "1.0.0",
+ Author: "Container Census",
+ Capabilities: []string{
+ "data_source",
+ "ui_tab",
+ "ui_badge",
+ "settings",
+ },
+ BuiltIn: true,
+ }
+}
+
+// Init initializes the plugin
+func (p *Plugin) Init(ctx context.Context, deps plugins.PluginDependencies) error {
+ p.deps = deps
+
+ // Load instances from storage
+ if err := p.loadInstances(); err != nil {
+ log.Printf("NPM plugin: failed to load instances: %v", err)
+ }
+
+ return nil
+}
+
+// Start starts the plugin background tasks
+func (p *Plugin) Start(ctx context.Context) error {
+ // Initial sync
+ p.syncAllInstances()
+
+ // Start periodic sync (every 5 minutes)
+ p.syncTicker = time.NewTicker(5 * time.Minute)
+ go func() {
+ for {
+ select {
+ case <-p.syncTicker.C:
+ p.syncAllInstances()
+ case <-p.stopChan:
+ return
+ }
+ }
+ }()
+
+ return nil
+}
+
+// Stop stops the plugin
+func (p *Plugin) Stop(ctx context.Context) error {
+ if p.syncTicker != nil {
+ p.syncTicker.Stop()
+ }
+ close(p.stopChan)
+ return nil
+}
+
+// Routes returns the plugin's API routes
+func (p *Plugin) Routes() []plugins.Route {
+ return []plugins.Route{
+ {Path: "/instances", Method: "GET", Handler: p.handleGetInstances},
+ {Path: "/instances", Method: "POST", Handler: p.handleAddInstance},
+ {Path: "/instances/{id}", Method: "GET", Handler: p.handleGetInstance},
+ {Path: "/instances/{id}", Method: "PUT", Handler: p.handleUpdateInstance},
+ {Path: "/instances/{id}", Method: "DELETE", Handler: p.handleDeleteInstance},
+ {Path: "/instances/{id}/test", Method: "POST", Handler: p.handleTestInstance},
+ {Path: "/instances/{id}/sync", Method: "POST", Handler: p.handleSyncInstance},
+ {Path: "/proxy-hosts", Method: "GET", Handler: p.handleGetProxyHosts},
+ {Path: "/exposed", Method: "GET", Handler: p.handleGetExposed},
+ {Path: "/tab", Method: "GET", Handler: p.handleGetTab},
+ }
+}
+
+// Tab returns the tab definition for the plugin
+func (p *Plugin) Tab() *plugins.TabDefinition {
+ return &plugins.TabDefinition{
+ ID: "npm",
+ Label: "Nginx Proxy Manager",
+ Icon: "π",
+ Order: 100,
+ ScriptURL: "/plugins/npm.js",
+ InitFunc: "npmPluginInit",
+ }
+}
+
+// Badges returns badge providers
+func (p *Plugin) Badges() []plugins.BadgeProvider {
+ return []plugins.BadgeProvider{p}
+}
+
+// GetBadge returns a badge for a container if it's exposed via NPM
+func (p *Plugin) GetBadge(ctx context.Context, container models.Container) (*plugins.Badge, error) {
+ containerKey := fmt.Sprintf("%d-%s", container.HostID, container.ID)
+
+ p.mu.RLock()
+ mappings, exists := p.mappings[containerKey]
+ p.mu.RUnlock()
+
+ if !exists || len(mappings) == 0 {
+ return nil, nil
+ }
+
+ // Get the first (primary) mapping
+ mapping := mappings[0]
+ domain := ""
+ if len(mapping.DomainNames) > 0 {
+ domain = mapping.DomainNames[0]
+ }
+
+ badge := &plugins.Badge{
+ ID: "npm-exposed",
+ Label: domain,
+ Icon: "π",
+ Color: "info",
+ Priority: 100,
+ Tooltip: fmt.Sprintf("Exposed via %s", mapping.InstanceName),
+ }
+
+ if domain != "" {
+ scheme := "http"
+ if mapping.SSLEnabled {
+ scheme = "https"
+ }
+ badge.Link = fmt.Sprintf("%s://%s", scheme, domain)
+ }
+
+ return badge, nil
+}
+
+// GetBadgeID returns a unique identifier for this badge provider
+func (p *Plugin) GetBadgeID() string {
+ return "npm-exposed"
+}
+
+// ContainerEnricher returns the container enricher
+func (p *Plugin) ContainerEnricher() plugins.ContainerEnricher {
+ return p
+}
+
+// Enrich adds NPM data to a container
+func (p *Plugin) Enrich(ctx context.Context, container *models.Container) error {
+ containerKey := fmt.Sprintf("%d-%s", container.HostID, container.ID)
+
+ p.mu.RLock()
+ mappings, exists := p.mappings[containerKey]
+ p.mu.RUnlock()
+
+ if !exists || len(mappings) == 0 {
+ return nil
+ }
+
+ if container.PluginData == nil {
+ container.PluginData = make(map[string]interface{})
+ }
+
+ container.PluginData["npm"] = map[string]interface{}{
+ "exposed": true,
+ "mappings": mappings,
+ }
+
+ return nil
+}
+
+// GetEnrichmentKey returns the key used in PluginData map
+func (p *Plugin) GetEnrichmentKey() string {
+ return "npm"
+}
+
+// Settings returns the settings definition
+func (p *Plugin) Settings() *plugins.SettingsDefinition {
+ return &plugins.SettingsDefinition{
+ Fields: []plugins.SettingsField{
+ {
+ Key: "sync_interval",
+ Label: "Sync Interval (minutes)",
+ Type: "number",
+ Default: "5",
+ Description: "How often to sync with NPM instances",
+ },
+ },
+ }
+}
+
+// NotificationChannelFactory returns nil (NPM doesn't provide notification channels)
+func (p *Plugin) NotificationChannelFactory() plugins.ChannelFactory {
+ return nil
+}
+
+// loadInstances loads NPM instances from storage
+func (p *Plugin) loadInstances() error {
+ data, err := p.deps.DB.List("instances/")
+ if err != nil {
+ return err
+ }
+
+ p.mu.Lock()
+ defer p.mu.Unlock()
+
+ for _, v := range data {
+ var inst NPMInstance
+ if err := json.Unmarshal(v, &inst); err != nil {
+ continue
+ }
+ inst.client = NewClient(inst.URL, inst.Email, inst.Password)
+ p.instances[inst.ID] = &inst
+ }
+
+ return nil
+}
+
+// saveInstance saves an NPM instance to storage
+func (p *Plugin) saveInstance(inst *NPMInstance) error {
+ data, err := json.Marshal(inst)
+ if err != nil {
+ return err
+ }
+ return p.deps.DB.Set(fmt.Sprintf("instances/%d", inst.ID), data)
+}
+
+// deleteInstance removes an NPM instance from storage
+func (p *Plugin) deleteInstance(id int64) error {
+ return p.deps.DB.Delete(fmt.Sprintf("instances/%d", id))
+}
+
+// syncAllInstances syncs all enabled NPM instances
+func (p *Plugin) syncAllInstances() {
+ p.mu.RLock()
+ instances := make([]*NPMInstance, 0, len(p.instances))
+ for _, inst := range p.instances {
+ if inst.Enabled {
+ instances = append(instances, inst)
+ }
+ }
+ p.mu.RUnlock()
+
+ for _, inst := range instances {
+ p.syncInstance(inst)
+ }
+
+ // Rebuild container mappings
+ p.rebuildMappings()
+}
+
+// syncInstance syncs a single NPM instance
+func (p *Plugin) syncInstance(inst *NPMInstance) {
+ if inst.client == nil {
+ inst.client = NewClient(inst.URL, inst.Email, inst.Password)
+ }
+
+ hosts, err := inst.client.GetProxyHosts()
+ if err != nil {
+ p.mu.Lock()
+ inst.LastError = err.Error()
+ p.mu.Unlock()
+ log.Printf("NPM plugin: failed to sync instance %s: %v", inst.Name, err)
+ return
+ }
+
+ p.mu.Lock()
+ p.proxyHosts[inst.ID] = hosts
+ inst.LastSync = time.Now()
+ inst.LastError = ""
+ p.mu.Unlock()
+
+ if err := p.saveInstance(inst); err != nil {
+ log.Printf("NPM plugin: failed to save instance state: %v", err)
+ }
+
+ log.Printf("NPM plugin: synced %d proxy hosts from %s", len(hosts), inst.Name)
+}
+
+// rebuildMappings rebuilds container-to-proxy-host mappings
+func (p *Plugin) rebuildMappings() {
+ containers := p.deps.Containers.GetContainers()
+
+ newMappings := make(map[string][]ProxyHostMapping)
+
+ p.mu.RLock()
+ for instanceID, hosts := range p.proxyHosts {
+ inst := p.instances[instanceID]
+ if inst == nil {
+ continue
+ }
+
+ for _, host := range hosts {
+ for _, container := range containers {
+ if p.matchContainer(container, host) {
+ containerKey := fmt.Sprintf("%d-%s", container.HostID, container.ID)
+ mapping := ProxyHostMapping{
+ InstanceID: instanceID,
+ InstanceName: inst.Name,
+ ProxyHostID: host.ID,
+ DomainNames: host.DomainNames,
+ SSLEnabled: host.CertificateID > 0,
+ Enabled: host.Enabled,
+ MatchType: "ip_port",
+ }
+ newMappings[containerKey] = append(newMappings[containerKey], mapping)
+ }
+ }
+ }
+ }
+ p.mu.RUnlock()
+
+ p.mu.Lock()
+ p.mappings = newMappings
+ p.mu.Unlock()
+}
+
+// matchContainer checks if a container matches an NPM proxy host
+func (p *Plugin) matchContainer(container models.Container, host ProxyHost) bool {
+ // Match by container IP + port
+ for _, nd := range container.NetworkDetails {
+ for _, port := range container.Ports {
+ if nd.IPAddress == host.ForwardHost && port.PrivatePort == host.ForwardPort {
+ return true
+ }
+ }
+ }
+
+ // Match by container name (if NPM uses Docker DNS)
+ if host.ForwardHost == container.Name {
+ return true
+ }
+
+ // Match by container name prefix (common pattern: container_name or containername)
+ containerNameClean := container.Name
+ if len(containerNameClean) > 0 && containerNameClean[0] == '/' {
+ containerNameClean = containerNameClean[1:]
+ }
+ if host.ForwardHost == containerNameClean {
+ return true
+ }
+
+ return false
+}
+
+// HTTP Handlers
+
+func (p *Plugin) handleGetInstances(w http.ResponseWriter, r *http.Request) {
+ p.mu.RLock()
+ instances := make([]*NPMInstance, 0, len(p.instances))
+ for _, inst := range p.instances {
+ // Don't include password
+ safe := &NPMInstance{
+ ID: inst.ID,
+ Name: inst.Name,
+ URL: inst.URL,
+ Email: inst.Email,
+ Enabled: inst.Enabled,
+ LastSync: inst.LastSync,
+ LastError: inst.LastError,
+ }
+ instances = append(instances, safe)
+ }
+ p.mu.RUnlock()
+
+ writeJSON(w, instances)
+}
+
+func (p *Plugin) handleAddInstance(w http.ResponseWriter, r *http.Request) {
+ var inst NPMInstance
+ if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ // Generate ID
+ p.mu.Lock()
+ maxID := int64(0)
+ for id := range p.instances {
+ if id > maxID {
+ maxID = id
+ }
+ }
+ inst.ID = maxID + 1
+ inst.Enabled = true
+ inst.client = NewClient(inst.URL, inst.Email, inst.Password)
+ p.instances[inst.ID] = &inst
+ p.mu.Unlock()
+
+ if err := p.saveInstance(&inst); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Sync the new instance
+ go func() {
+ p.syncInstance(&inst)
+ p.rebuildMappings()
+ }()
+
+ writeJSON(w, map[string]interface{}{"id": inst.ID})
+}
+
+func (p *Plugin) handleGetInstance(w http.ResponseWriter, r *http.Request) {
+ id := getPathParam(r, "id")
+ instID, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid instance ID", http.StatusBadRequest)
+ return
+ }
+
+ p.mu.RLock()
+ inst, exists := p.instances[instID]
+ p.mu.RUnlock()
+
+ if !exists {
+ http.Error(w, "instance not found", http.StatusNotFound)
+ return
+ }
+
+ safe := &NPMInstance{
+ ID: inst.ID,
+ Name: inst.Name,
+ URL: inst.URL,
+ Email: inst.Email,
+ Enabled: inst.Enabled,
+ LastSync: inst.LastSync,
+ LastError: inst.LastError,
+ }
+ writeJSON(w, safe)
+}
+
+func (p *Plugin) handleUpdateInstance(w http.ResponseWriter, r *http.Request) {
+ id := getPathParam(r, "id")
+ instID, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid instance ID", http.StatusBadRequest)
+ return
+ }
+
+ p.mu.Lock()
+ inst, exists := p.instances[instID]
+ if !exists {
+ p.mu.Unlock()
+ http.Error(w, "instance not found", http.StatusNotFound)
+ return
+ }
+
+ var update NPMInstance
+ if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
+ p.mu.Unlock()
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ inst.Name = update.Name
+ inst.URL = update.URL
+ inst.Email = update.Email
+ if update.Password != "" {
+ inst.Password = update.Password
+ }
+ inst.Enabled = update.Enabled
+ inst.client = NewClient(inst.URL, inst.Email, inst.Password)
+ p.mu.Unlock()
+
+ if err := p.saveInstance(inst); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func (p *Plugin) handleDeleteInstance(w http.ResponseWriter, r *http.Request) {
+ id := getPathParam(r, "id")
+ instID, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid instance ID", http.StatusBadRequest)
+ return
+ }
+
+ p.mu.Lock()
+ delete(p.instances, instID)
+ delete(p.proxyHosts, instID)
+ p.mu.Unlock()
+
+ if err := p.deleteInstance(instID); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ p.rebuildMappings()
+ w.WriteHeader(http.StatusOK)
+}
+
+func (p *Plugin) handleTestInstance(w http.ResponseWriter, r *http.Request) {
+ id := getPathParam(r, "id")
+ instID, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid instance ID", http.StatusBadRequest)
+ return
+ }
+
+ p.mu.RLock()
+ inst, exists := p.instances[instID]
+ p.mu.RUnlock()
+
+ if !exists {
+ http.Error(w, "instance not found", http.StatusNotFound)
+ return
+ }
+
+ client := NewClient(inst.URL, inst.Email, inst.Password)
+ if err := client.TestConnection(); err != nil {
+ writeJSON(w, map[string]interface{}{
+ "success": false,
+ "error": err.Error(),
+ })
+ return
+ }
+
+ writeJSON(w, map[string]interface{}{
+ "success": true,
+ })
+}
+
+func (p *Plugin) handleSyncInstance(w http.ResponseWriter, r *http.Request) {
+ id := getPathParam(r, "id")
+ instID, err := strconv.ParseInt(id, 10, 64)
+ if err != nil {
+ http.Error(w, "invalid instance ID", http.StatusBadRequest)
+ return
+ }
+
+ p.mu.RLock()
+ inst, exists := p.instances[instID]
+ p.mu.RUnlock()
+
+ if !exists {
+ http.Error(w, "instance not found", http.StatusNotFound)
+ return
+ }
+
+ p.syncInstance(inst)
+ p.rebuildMappings()
+
+ p.mu.RLock()
+ hostCount := len(p.proxyHosts[instID])
+ lastError := inst.LastError
+ p.mu.RUnlock()
+
+ writeJSON(w, map[string]interface{}{
+ "success": lastError == "",
+ "host_count": hostCount,
+ "error": lastError,
+ })
+}
+
+func (p *Plugin) handleGetProxyHosts(w http.ResponseWriter, r *http.Request) {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+
+ allHosts := make([]map[string]interface{}, 0)
+ for instanceID, hosts := range p.proxyHosts {
+ inst := p.instances[instanceID]
+ for _, host := range hosts {
+ allHosts = append(allHosts, map[string]interface{}{
+ "instance_id": instanceID,
+ "instance_name": inst.Name,
+ "host": host,
+ })
+ }
+ }
+
+ writeJSON(w, allHosts)
+}
+
+func (p *Plugin) handleGetExposed(w http.ResponseWriter, r *http.Request) {
+ p.mu.RLock()
+ defer p.mu.RUnlock()
+
+ exposed := make([]map[string]interface{}, 0)
+ for containerKey, mappings := range p.mappings {
+ exposed = append(exposed, map[string]interface{}{
+ "container_key": containerKey,
+ "mappings": mappings,
+ })
+ }
+
+ writeJSON(w, exposed)
+}
+
+func (p *Plugin) handleGetTab(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ w.Write([]byte(npmTabHTML))
+}
+
+// Helper functions
+
+func writeJSON(w http.ResponseWriter, data interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(data)
+}
+
+func getPathParam(r *http.Request, name string) string {
+ vars := mux.Vars(r)
+ return vars[name]
+}
+
+// Tab HTML template - JavaScript is loaded from /plugins/npm.js
+const npmTabHTML = `
+
+
+
+
+
+
+
+
+
Exposed Services
+
+
Loading exposed services...
+
+
+
+
+
+
+
+
+`
diff --git a/internal/plugins/event_bus.go b/internal/plugins/event_bus.go
new file mode 100644
index 0000000..458707c
--- /dev/null
+++ b/internal/plugins/event_bus.go
@@ -0,0 +1,222 @@
+package plugins
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+// EventBusImpl implements the EventBus interface
+type EventBusImpl struct {
+ mu sync.RWMutex
+ subscribers map[string][]eventSubscriber
+ nextID int
+}
+
+type eventSubscriber struct {
+ id int
+ handler EventHandler
+}
+
+// NewEventBus creates a new event bus
+func NewEventBus() *EventBusImpl {
+ return &EventBusImpl{
+ subscribers: make(map[string][]eventSubscriber),
+ }
+}
+
+// Subscribe registers a handler for an event type
+func (e *EventBusImpl) Subscribe(eventType string, handler EventHandler) func() {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ id := e.nextID
+ e.nextID++
+
+ e.subscribers[eventType] = append(e.subscribers[eventType], eventSubscriber{
+ id: id,
+ handler: handler,
+ })
+
+ // Return unsubscribe function
+ return func() {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ subs := e.subscribers[eventType]
+ for i, sub := range subs {
+ if sub.id == id {
+ e.subscribers[eventType] = append(subs[:i], subs[i+1:]...)
+ break
+ }
+ }
+ }
+}
+
+// Publish sends an event to all subscribers
+func (e *EventBusImpl) Publish(event Event) {
+ e.mu.RLock()
+ // Get subscribers for this event type
+ subs := make([]eventSubscriber, len(e.subscribers[event.Type]))
+ copy(subs, e.subscribers[event.Type])
+
+ // Also notify wildcard subscribers
+ wildcardSubs := make([]eventSubscriber, len(e.subscribers["*"]))
+ copy(wildcardSubs, e.subscribers["*"])
+ e.mu.RUnlock()
+
+ // Set timestamp if not set
+ if event.Timestamp.IsZero() {
+ event.Timestamp = time.Now()
+ }
+
+ ctx := context.Background()
+
+ // Notify specific subscribers
+ for _, sub := range subs {
+ go sub.handler(ctx, event)
+ }
+
+ // Notify wildcard subscribers
+ for _, sub := range wildcardSubs {
+ go sub.handler(ctx, event)
+ }
+}
+
+// PublishSync sends an event and waits for all handlers to complete
+func (e *EventBusImpl) PublishSync(ctx context.Context, event Event) {
+ e.mu.RLock()
+ subs := make([]eventSubscriber, len(e.subscribers[event.Type]))
+ copy(subs, e.subscribers[event.Type])
+ wildcardSubs := make([]eventSubscriber, len(e.subscribers["*"]))
+ copy(wildcardSubs, e.subscribers["*"])
+ e.mu.RUnlock()
+
+ if event.Timestamp.IsZero() {
+ event.Timestamp = time.Now()
+ }
+
+ var wg sync.WaitGroup
+
+ for _, sub := range subs {
+ wg.Add(1)
+ go func(s eventSubscriber) {
+ defer wg.Done()
+ s.handler(ctx, event)
+ }(sub)
+ }
+
+ for _, sub := range wildcardSubs {
+ wg.Add(1)
+ go func(s eventSubscriber) {
+ defer wg.Done()
+ s.handler(ctx, event)
+ }(sub)
+ }
+
+ wg.Wait()
+}
+
+// SubscriberCount returns the number of subscribers for an event type
+func (e *EventBusImpl) SubscriberCount(eventType string) int {
+ e.mu.RLock()
+ defer e.mu.RUnlock()
+ return len(e.subscribers[eventType])
+}
+
+// Helper functions to create common events
+
+// NewScanCompleteEvent creates a scan complete event
+func NewScanCompleteEvent(hostID int64, hostName string, containerCount int) Event {
+ return Event{
+ Type: EventScanComplete,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "host_name": hostName,
+ "container_count": containerCount,
+ },
+ }
+}
+
+// NewContainerStateChangeEvent creates a container state change event
+func NewContainerStateChangeEvent(hostID int64, containerID, containerName, oldState, newState string) Event {
+ return Event{
+ Type: EventContainerStateChange,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "container_id": containerID,
+ "container_name": containerName,
+ "old_state": oldState,
+ "new_state": newState,
+ },
+ }
+}
+
+// NewContainerCreatedEvent creates a container created event
+func NewContainerCreatedEvent(hostID int64, containerID, containerName, image string) Event {
+ return Event{
+ Type: EventContainerCreated,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "container_id": containerID,
+ "container_name": containerName,
+ "image": image,
+ },
+ }
+}
+
+// NewContainerRemovedEvent creates a container removed event
+func NewContainerRemovedEvent(hostID int64, containerID, containerName string) Event {
+ return Event{
+ Type: EventContainerRemoved,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "container_id": containerID,
+ "container_name": containerName,
+ },
+ }
+}
+
+// NewImageUpdatedEvent creates an image updated event
+func NewImageUpdatedEvent(hostID int64, containerID, containerName, oldImageID, newImageID string) Event {
+ return Event{
+ Type: EventImageUpdated,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "container_id": containerID,
+ "container_name": containerName,
+ "old_image_id": oldImageID,
+ "new_image_id": newImageID,
+ },
+ }
+}
+
+// NewHostAddedEvent creates a host added event
+func NewHostAddedEvent(hostID int64, hostName, address string) Event {
+ return Event{
+ Type: EventHostAdded,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "host_name": hostName,
+ "address": address,
+ },
+ }
+}
+
+// NewHostRemovedEvent creates a host removed event
+func NewHostRemovedEvent(hostID int64, hostName string) Event {
+ return Event{
+ Type: EventHostRemoved,
+ Timestamp: time.Now(),
+ Data: map[string]interface{}{
+ "host_id": hostID,
+ "host_name": hostName,
+ },
+ }
+}
diff --git a/internal/plugins/interface.go b/internal/plugins/interface.go
new file mode 100644
index 0000000..828b8b4
--- /dev/null
+++ b/internal/plugins/interface.go
@@ -0,0 +1,232 @@
+package plugins
+
+import (
+ "context"
+ "log"
+ "net/http"
+ "time"
+
+ "github.com/container-census/container-census/internal/models"
+)
+
+// Plugin represents a Container Census plugin
+type Plugin interface {
+ // Info returns plugin metadata
+ Info() PluginInfo
+
+ // Lifecycle methods
+ Init(ctx context.Context, deps PluginDependencies) error
+ Start(ctx context.Context) error
+ Stop(ctx context.Context) error
+
+ // Capabilities - return nil if not supported
+ Routes() []Route // API routes under /api/plugins/{id}/
+ Tab() *TabDefinition // UI tab (appears in Integrations dropdown)
+ Badges() []BadgeProvider // Badges on container cards
+ ContainerEnricher() ContainerEnricher // Enrich container data
+ Settings() *SettingsDefinition // Settings schema
+ NotificationChannelFactory() ChannelFactory // Notification channel factory
+}
+
+// PluginInfo contains plugin metadata
+type PluginInfo struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Version string `json:"version"`
+ Author string `json:"author"`
+ Homepage string `json:"homepage,omitempty"`
+ Capabilities []string `json:"capabilities"` // data_source, ui_tab, ui_badge, notification_channel, settings
+ BuiltIn bool `json:"built_in"`
+}
+
+// PluginDependencies provides access to core Census features
+type PluginDependencies struct {
+ DB PluginDB // Database access (scoped to plugin prefix)
+ Containers ContainerProvider // Access to container data
+ Hosts HostProvider // Access to host data
+ HTTPClient *http.Client // Pre-configured HTTP client
+ Logger PluginLogger // Scoped logger
+ EventBus EventBus // Subscribe to system events
+}
+
+// PluginDB provides scoped database access for plugins
+type PluginDB interface {
+ // Get retrieves a value by key
+ Get(key string) ([]byte, error)
+ // Set stores a value by key
+ Set(key string, value []byte) error
+ // Delete removes a key
+ Delete(key string) error
+ // List returns all key-value pairs with the given prefix
+ List(prefix string) (map[string][]byte, error)
+ // GetSetting retrieves a setting value
+ GetSetting(key string) (string, error)
+ // SetSetting stores a setting value
+ SetSetting(key string, value string) error
+ // GetAllSettings retrieves all settings
+ GetAllSettings() (map[string]string, error)
+}
+
+// ContainerProvider provides read access to container data
+type ContainerProvider interface {
+ // GetContainers returns all containers from the latest scan
+ GetContainers() []models.Container
+ // GetContainerByID returns a specific container
+ GetContainerByID(hostID int64, containerID string) (*models.Container, error)
+}
+
+// HostProvider provides read access to host data
+type HostProvider interface {
+ // GetHosts returns all configured hosts
+ GetHosts() ([]models.Host, error)
+ // GetHostByID returns a specific host
+ GetHostByID(id int64) (*models.Host, error)
+}
+
+// PluginLogger provides scoped logging for plugins
+type PluginLogger interface {
+ Debug(msg string, args ...interface{})
+ Info(msg string, args ...interface{})
+ Warn(msg string, args ...interface{})
+ Error(msg string, args ...interface{})
+}
+
+// Route defines an API route provided by a plugin
+type Route struct {
+ Path string // Path relative to /api/plugins/{plugin_id}/
+ Method string // HTTP method (GET, POST, PUT, DELETE)
+ Handler http.HandlerFunc // Handler function
+}
+
+// TabDefinition defines a UI tab provided by a plugin
+type TabDefinition struct {
+ ID string `json:"id"` // Unique tab ID (used in URL hash)
+ Label string `json:"label"` // Display label
+ Icon string `json:"icon"` // Emoji or icon
+ Order int `json:"order"` // Sort order (lower = first)
+ ContentHTML string `json:"content_html"` // Initial HTML content
+ ScriptJS string `json:"script_js"` // JavaScript code for tab (inline)
+ ScriptURL string `json:"script_url,omitempty"` // URL to external JavaScript file
+ InitFunc string `json:"init_func,omitempty"` // Function name to call after loading script
+ StyleCSS string `json:"style_css"` // CSS styles for tab
+}
+
+// Badge represents a visual badge on a container card
+type Badge struct {
+ ID string `json:"id"`
+ PluginID string `json:"plugin_id"`
+ Label string `json:"label"`
+ Icon string `json:"icon"`
+ Color string `json:"color"` // success, warning, danger, info, secondary
+ Tooltip string `json:"tooltip"`
+ Link string `json:"link,omitempty"`
+ Priority int `json:"priority"` // Higher = shown first
+}
+
+// BadgeProvider generates badges for containers
+type BadgeProvider interface {
+ // GetBadge returns a badge for the container, or nil if not applicable
+ GetBadge(ctx context.Context, container models.Container) (*Badge, error)
+ // GetBadgeID returns a unique identifier for this badge provider
+ GetBadgeID() string
+}
+
+// ContainerEnricher adds data to container objects
+type ContainerEnricher interface {
+ // Enrich adds plugin-specific data to the container's PluginData map
+ Enrich(ctx context.Context, container *models.Container) error
+ // GetEnrichmentKey returns the key used in PluginData map
+ GetEnrichmentKey() string
+}
+
+// SettingsDefinition defines the settings schema for a plugin
+type SettingsDefinition struct {
+ Fields []SettingsField `json:"fields"`
+}
+
+// SettingsField defines a single setting field
+type SettingsField struct {
+ Key string `json:"key"`
+ Label string `json:"label"`
+ Description string `json:"description,omitempty"`
+ Type string `json:"type"` // text, password, number, boolean, select
+ Default string `json:"default,omitempty"`
+ Required bool `json:"required"`
+ Options []Option `json:"options,omitempty"` // For select type
+ Min *int `json:"min,omitempty"` // For number type
+ Max *int `json:"max,omitempty"` // For number type
+}
+
+// Option represents a select option
+type Option struct {
+ Value string `json:"value"`
+ Label string `json:"label"`
+}
+
+// ChannelFactory creates notification channel instances
+type ChannelFactory interface {
+ // CreateChannel creates a notification channel from config
+ CreateChannel(config map[string]interface{}) (NotificationChannel, error)
+ // GetType returns the channel type name
+ GetType() string
+ // GetConfigSchema returns the configuration schema
+ GetConfigSchema() []SettingsField
+}
+
+// NotificationChannel sends notifications
+type NotificationChannel interface {
+ Send(ctx context.Context, message string, event models.NotificationEvent) error
+ Test(ctx context.Context) error
+}
+
+// EventBus allows plugins to subscribe to system events
+type EventBus interface {
+ // Subscribe registers a handler for an event type
+ // Returns an unsubscribe function
+ Subscribe(eventType string, handler EventHandler) func()
+ // Publish sends an event to all subscribers
+ Publish(event Event)
+}
+
+// EventHandler handles events
+type EventHandler func(ctx context.Context, event Event)
+
+// Event represents a system event
+type Event struct {
+ Type string `json:"type"`
+ Timestamp time.Time `json:"timestamp"`
+ Data map[string]interface{} `json:"data"`
+}
+
+// Common event types
+const (
+ EventScanComplete = "scan_complete"
+ EventContainerStateChange = "container_state_change"
+ EventContainerCreated = "container_created"
+ EventContainerRemoved = "container_removed"
+ EventImageUpdated = "image_updated"
+ EventHostAdded = "host_added"
+ EventHostRemoved = "host_removed"
+)
+
+// DefaultPluginLogger provides a simple logger implementation
+type DefaultPluginLogger struct {
+ Prefix string
+}
+
+func (l *DefaultPluginLogger) Debug(msg string, args ...interface{}) {
+ log.Printf("[DEBUG] [%s] %s %v", l.Prefix, msg, args)
+}
+
+func (l *DefaultPluginLogger) Info(msg string, args ...interface{}) {
+ log.Printf("[INFO] [%s] %s %v", l.Prefix, msg, args)
+}
+
+func (l *DefaultPluginLogger) Warn(msg string, args ...interface{}) {
+ log.Printf("[WARN] [%s] %s %v", l.Prefix, msg, args)
+}
+
+func (l *DefaultPluginLogger) Error(msg string, args ...interface{}) {
+ log.Printf("[ERROR] [%s] %s %v", l.Prefix, msg, args)
+}
diff --git a/internal/plugins/manager.go b/internal/plugins/manager.go
new file mode 100644
index 0000000..e6111ed
--- /dev/null
+++ b/internal/plugins/manager.go
@@ -0,0 +1,455 @@
+package plugins
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net/http"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/container-census/container-census/internal/models"
+ "github.com/container-census/container-census/internal/storage"
+ "github.com/gorilla/mux"
+)
+
+// Manager manages plugin lifecycle and provides access to plugin features
+type Manager struct {
+ mu sync.RWMutex
+ plugins map[string]Plugin
+ pluginOrder []string // Order of plugin registration
+ builtInFactories map[string]PluginFactory
+ db *storage.DB
+ containers ContainerProvider
+ hosts HostProvider
+ eventBus *EventBusImpl
+ router *mux.Router
+ started bool
+}
+
+// PluginFactory creates a new plugin instance
+type PluginFactory func() Plugin
+
+// NewManager creates a new plugin manager
+func NewManager(db *storage.DB, containers ContainerProvider, hosts HostProvider) *Manager {
+ return &Manager{
+ plugins: make(map[string]Plugin),
+ pluginOrder: make([]string, 0),
+ builtInFactories: make(map[string]PluginFactory),
+ db: db,
+ containers: containers,
+ hosts: hosts,
+ eventBus: NewEventBus(),
+ }
+}
+
+// SetRouter sets the router for mounting plugin routes
+func (m *Manager) SetRouter(router *mux.Router) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.router = router
+}
+
+// RegisterBuiltIn registers a built-in plugin factory
+func (m *Manager) RegisterBuiltIn(id string, factory PluginFactory) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.builtInFactories[id] = factory
+ log.Printf("Registered built-in plugin factory: %s", id)
+}
+
+// LoadBuiltInPlugins loads all registered built-in plugins
+func (m *Manager) LoadBuiltInPlugins(ctx context.Context) error {
+ m.mu.Lock()
+ factories := make(map[string]PluginFactory)
+ for id, f := range m.builtInFactories {
+ factories[id] = f
+ }
+ m.mu.Unlock()
+
+ for id, factory := range factories {
+ // Check if plugin is disabled in database
+ record, err := m.db.GetPlugin(id)
+ if err == nil && record != nil && !record.Enabled {
+ log.Printf("Skipping disabled plugin: %s", id)
+ continue
+ }
+
+ plugin := factory()
+ if err := m.loadPlugin(ctx, plugin); err != nil {
+ log.Printf("Failed to load built-in plugin %s: %v", id, err)
+ continue
+ }
+ }
+
+ return nil
+}
+
+// loadPlugin initializes and registers a plugin
+func (m *Manager) loadPlugin(ctx context.Context, plugin Plugin) error {
+ info := plugin.Info()
+
+ // Create scoped dependencies
+ deps := PluginDependencies{
+ DB: &scopedPluginDB{db: m.db, pluginID: info.ID},
+ Containers: m.containers,
+ Hosts: m.hosts,
+ HTTPClient: &http.Client{Timeout: 30 * time.Second},
+ Logger: &DefaultPluginLogger{Prefix: info.ID},
+ EventBus: m.eventBus,
+ }
+
+ // Initialize plugin
+ if err := plugin.Init(ctx, deps); err != nil {
+ return fmt.Errorf("failed to initialize plugin %s: %w", info.ID, err)
+ }
+
+ // Save plugin record
+ record := &storage.PluginRecord{
+ ID: info.ID,
+ Name: info.Name,
+ Version: info.Version,
+ SourceType: "built_in",
+ Enabled: true,
+ InstalledAt: time.Now(),
+ UpdatedAt: time.Now(),
+ }
+ if !info.BuiltIn {
+ record.SourceType = "github"
+ }
+ if err := m.db.SavePlugin(record); err != nil {
+ log.Printf("Warning: failed to save plugin record for %s: %v", info.ID, err)
+ }
+
+ // Register plugin
+ m.mu.Lock()
+ m.plugins[info.ID] = plugin
+ m.pluginOrder = append(m.pluginOrder, info.ID)
+ m.mu.Unlock()
+
+ // Mount routes if router is available
+ if m.router != nil {
+ m.mountPluginRoutes(info.ID, plugin)
+ }
+
+ log.Printf("Loaded plugin: %s v%s", info.Name, info.Version)
+ return nil
+}
+
+// mountPluginRoutes mounts a plugin's API routes
+func (m *Manager) mountPluginRoutes(pluginID string, plugin Plugin) {
+ routes := plugin.Routes()
+ if len(routes) == 0 {
+ return
+ }
+
+ for _, route := range routes {
+ // Router is already prefixed with /api, so we add /p/{id}{path}
+ // Using /p/ instead of /plugins/ to avoid conflict with /plugins/{id} management routes
+ path := fmt.Sprintf("/p/%s%s", pluginID, route.Path)
+ m.router.HandleFunc(path, route.Handler).Methods(route.Method)
+ log.Printf("Mounted plugin route: %s /api%s", route.Method, path)
+ }
+}
+
+// Start starts all loaded plugins
+func (m *Manager) Start(ctx context.Context) error {
+ m.mu.Lock()
+ if m.started {
+ m.mu.Unlock()
+ return nil
+ }
+ plugins := make([]Plugin, 0, len(m.plugins))
+ for _, id := range m.pluginOrder {
+ plugins = append(plugins, m.plugins[id])
+ }
+ m.mu.Unlock()
+
+ for _, plugin := range plugins {
+ info := plugin.Info()
+ if err := plugin.Start(ctx); err != nil {
+ log.Printf("Failed to start plugin %s: %v", info.ID, err)
+ continue
+ }
+ log.Printf("Started plugin: %s", info.Name)
+ }
+
+ m.mu.Lock()
+ m.started = true
+ m.mu.Unlock()
+
+ return nil
+}
+
+// Stop stops all plugins
+func (m *Manager) Stop(ctx context.Context) error {
+ m.mu.Lock()
+ plugins := make([]Plugin, 0, len(m.plugins))
+ // Stop in reverse order
+ for i := len(m.pluginOrder) - 1; i >= 0; i-- {
+ plugins = append(plugins, m.plugins[m.pluginOrder[i]])
+ }
+ m.mu.Unlock()
+
+ for _, plugin := range plugins {
+ info := plugin.Info()
+ if err := plugin.Stop(ctx); err != nil {
+ log.Printf("Failed to stop plugin %s: %v", info.ID, err)
+ continue
+ }
+ log.Printf("Stopped plugin: %s", info.Name)
+ }
+
+ m.mu.Lock()
+ m.started = false
+ m.mu.Unlock()
+
+ return nil
+}
+
+// GetPlugin returns a plugin by ID
+func (m *Manager) GetPlugin(id string) (Plugin, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ plugin, ok := m.plugins[id]
+ return plugin, ok
+}
+
+// GetAllPlugins returns all loaded plugins
+func (m *Manager) GetAllPlugins() []Plugin {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ plugins := make([]Plugin, 0, len(m.plugins))
+ for _, id := range m.pluginOrder {
+ plugins = append(plugins, m.plugins[id])
+ }
+ return plugins
+}
+
+// GetAllPluginInfo returns info for all plugins (including disabled)
+func (m *Manager) GetAllPluginInfo() ([]PluginInfo, error) {
+ // Get registered built-in plugins
+ m.mu.RLock()
+ loadedPlugins := make(map[string]Plugin)
+ for id, p := range m.plugins {
+ loadedPlugins[id] = p
+ }
+ builtInIDs := make([]string, 0, len(m.builtInFactories))
+ for id := range m.builtInFactories {
+ builtInIDs = append(builtInIDs, id)
+ }
+ m.mu.RUnlock()
+
+ // Get database records
+ records, err := m.db.GetAllPlugins()
+ if err != nil {
+ return nil, err
+ }
+ recordMap := make(map[string]*storage.PluginRecord)
+ for _, r := range records {
+ recordMap[r.ID] = r
+ }
+
+ var result []PluginInfo
+
+ // Add loaded plugins
+ for _, plugin := range loadedPlugins {
+ info := plugin.Info()
+ result = append(result, info)
+ }
+
+ // Add disabled built-in plugins
+ for _, id := range builtInIDs {
+ if _, loaded := loadedPlugins[id]; !loaded {
+ if record, exists := recordMap[id]; exists && !record.Enabled {
+ // Create factory to get info
+ factory := m.builtInFactories[id]
+ plugin := factory()
+ info := plugin.Info()
+ result = append(result, info)
+ }
+ }
+ }
+
+ return result, nil
+}
+
+// GetAllTabs returns tab definitions from all plugins
+func (m *Manager) GetAllTabs() []TabDefinition {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+
+ var tabs []TabDefinition
+ for _, id := range m.pluginOrder {
+ plugin := m.plugins[id]
+ if tab := plugin.Tab(); tab != nil {
+ tabs = append(tabs, *tab)
+ }
+ }
+
+ // Sort by order
+ sort.Slice(tabs, func(i, j int) bool {
+ return tabs[i].Order < tabs[j].Order
+ })
+
+ return tabs
+}
+
+// GetBadgesForContainer returns all badges for a container from all plugins
+func (m *Manager) GetBadgesForContainer(ctx context.Context, container models.Container) []Badge {
+ m.mu.RLock()
+ plugins := make([]Plugin, 0, len(m.plugins))
+ for _, id := range m.pluginOrder {
+ plugins = append(plugins, m.plugins[id])
+ }
+ m.mu.RUnlock()
+
+ var badges []Badge
+ for _, plugin := range plugins {
+ providers := plugin.Badges()
+ for _, provider := range providers {
+ badge, err := provider.GetBadge(ctx, container)
+ if err != nil {
+ log.Printf("Error getting badge from plugin: %v", err)
+ continue
+ }
+ if badge != nil {
+ badge.PluginID = plugin.Info().ID
+ badges = append(badges, *badge)
+ }
+ }
+ }
+
+ // Sort by priority (higher first)
+ sort.Slice(badges, func(i, j int) bool {
+ return badges[i].Priority > badges[j].Priority
+ })
+
+ return badges
+}
+
+// EnrichContainer enriches a container with data from all plugins
+func (m *Manager) EnrichContainer(ctx context.Context, container *models.Container) {
+ m.mu.RLock()
+ plugins := make([]Plugin, 0, len(m.plugins))
+ for _, id := range m.pluginOrder {
+ plugins = append(plugins, m.plugins[id])
+ }
+ m.mu.RUnlock()
+
+ if container.PluginData == nil {
+ container.PluginData = make(map[string]interface{})
+ }
+
+ for _, plugin := range plugins {
+ enricher := plugin.ContainerEnricher()
+ if enricher == nil {
+ continue
+ }
+ if err := enricher.Enrich(ctx, container); err != nil {
+ log.Printf("Error enriching container from plugin %s: %v", plugin.Info().ID, err)
+ }
+ }
+}
+
+// EnablePlugin enables a plugin
+func (m *Manager) EnablePlugin(ctx context.Context, id string) error {
+ if err := m.db.SetPluginEnabled(id, true); err != nil {
+ return err
+ }
+
+ // If it's a built-in plugin, load it
+ m.mu.RLock()
+ factory, isBuiltIn := m.builtInFactories[id]
+ _, alreadyLoaded := m.plugins[id]
+ m.mu.RUnlock()
+
+ if isBuiltIn && !alreadyLoaded {
+ plugin := factory()
+ if err := m.loadPlugin(ctx, plugin); err != nil {
+ return err
+ }
+ if m.started {
+ if err := plugin.Start(ctx); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// DisablePlugin disables a plugin
+func (m *Manager) DisablePlugin(ctx context.Context, id string) error {
+ if err := m.db.SetPluginEnabled(id, false); err != nil {
+ return err
+ }
+
+ // Stop and unload the plugin
+ m.mu.Lock()
+ plugin, loaded := m.plugins[id]
+ if loaded {
+ delete(m.plugins, id)
+ // Remove from order
+ for i, pid := range m.pluginOrder {
+ if pid == id {
+ m.pluginOrder = append(m.pluginOrder[:i], m.pluginOrder[i+1:]...)
+ break
+ }
+ }
+ }
+ m.mu.Unlock()
+
+ if loaded {
+ if err := plugin.Stop(ctx); err != nil {
+ log.Printf("Error stopping plugin %s: %v", id, err)
+ }
+ }
+
+ return nil
+}
+
+// GetEventBus returns the event bus
+func (m *Manager) GetEventBus() *EventBusImpl {
+ return m.eventBus
+}
+
+// PublishEvent publishes an event to all subscribers
+func (m *Manager) PublishEvent(event Event) {
+ m.eventBus.Publish(event)
+}
+
+// scopedPluginDB provides scoped database access for a specific plugin
+type scopedPluginDB struct {
+ db *storage.DB
+ pluginID string
+}
+
+func (s *scopedPluginDB) Get(key string) ([]byte, error) {
+ return s.db.GetPluginData(s.pluginID, key)
+}
+
+func (s *scopedPluginDB) Set(key string, value []byte) error {
+ return s.db.SetPluginData(s.pluginID, key, value)
+}
+
+func (s *scopedPluginDB) Delete(key string) error {
+ return s.db.DeletePluginData(s.pluginID, key)
+}
+
+func (s *scopedPluginDB) List(prefix string) (map[string][]byte, error) {
+ return s.db.ListPluginData(s.pluginID, prefix)
+}
+
+func (s *scopedPluginDB) GetSetting(key string) (string, error) {
+ return s.db.GetPluginSetting(s.pluginID, key)
+}
+
+func (s *scopedPluginDB) SetSetting(key string, value string) error {
+ return s.db.SetPluginSetting(s.pluginID, key, value)
+}
+
+func (s *scopedPluginDB) GetAllSettings() (map[string]string, error) {
+ return s.db.GetAllPluginSettings(s.pluginID)
+}
diff --git a/internal/scanner/scanner.go b/internal/scanner/scanner.go
index 5c64949..af86c8f 100644
--- a/internal/scanner/scanner.go
+++ b/internal/scanner/scanner.go
@@ -131,18 +131,34 @@ func (s *Scanner) ScanHost(ctx context.Context, host models.Host) ([]models.Cont
// Inspect container for detailed info (restart count, connections, etc.)
var restartCount int
var networks []string
+ var networkDetails []models.NetworkDetail
var volumes []models.VolumeMount
var links []string
var composeProject string
+ var startedAt time.Time
containerJSON, err := dockerClient.ContainerInspect(ctx, c.ID)
if err == nil {
restartCount = containerJSON.RestartCount
- // Extract network connections
+ // Extract StartedAt for uptime tracking
+ if containerJSON.State != nil && containerJSON.State.StartedAt != "" {
+ if parsed, parseErr := time.Parse(time.RFC3339Nano, containerJSON.State.StartedAt); parseErr == nil {
+ startedAt = parsed
+ }
+ }
+
+ // Extract network connections with details
if containerJSON.NetworkSettings != nil && containerJSON.NetworkSettings.Networks != nil {
- for networkName := range containerJSON.NetworkSettings.Networks {
+ for networkName, networkSettings := range containerJSON.NetworkSettings.Networks {
networks = append(networks, networkName)
+ // Add detailed network info for plugin matching
+ networkDetails = append(networkDetails, models.NetworkDetail{
+ NetworkName: networkName,
+ IPAddress: networkSettings.IPAddress,
+ Gateway: networkSettings.Gateway,
+ Aliases: networkSettings.Aliases,
+ })
}
}
@@ -194,9 +210,11 @@ func (s *Scanner) ScanHost(ctx context.Context, host models.Host) ([]models.Cont
HostName: host.Name,
ScannedAt: now,
Networks: networks,
+ NetworkDetails: networkDetails,
Volumes: volumes,
Links: links,
ComposeProject: composeProject,
+ StartedAt: startedAt,
}
result = append(result, container)
diff --git a/internal/storage/db.go b/internal/storage/db.go
index 79a393e..46ae875 100644
--- a/internal/storage/db.go
+++ b/internal/storage/db.go
@@ -370,6 +370,11 @@ func (db *DB) initSchema() error {
return err
}
+ // Initialize plugin schema
+ if err := db.initPluginSchema(); err != nil {
+ return err
+ }
+
// Run migrations for existing databases
return db.runMigrations()
}
@@ -845,6 +850,39 @@ func (db *DB) GetContainersByHost(hostID int64) ([]models.Container, error) {
return db.scanContainers(rows)
}
+// GetContainerByID returns a specific container by host ID and container ID
+func (db *DB) GetContainerByID(hostID int64, containerID string) (*models.Container, error) {
+ query := `
+ SELECT c.id, c.name, c.image, c.image_id, c.image_digest, c.image_tags, c.state, c.status,
+ c.ports, c.labels, c.created, c.host_id, c.host_name, c.scanned_at,
+ c.networks, c.volumes, c.links, c.compose_project,
+ c.cpu_percent, c.memory_usage, c.memory_limit, c.memory_percent,
+ c.update_available, c.last_update_check
+ FROM containers c
+ INNER JOIN (
+ SELECT MAX(scanned_at) as max_scan
+ FROM containers
+ WHERE host_id = ?
+ ) latest ON c.scanned_at = latest.max_scan
+ WHERE c.host_id = ? AND c.id = ?
+ `
+
+ rows, err := db.conn.Query(query, hostID, hostID, containerID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ containers, err := db.scanContainers(rows)
+ if err != nil {
+ return nil, err
+ }
+ if len(containers) == 0 {
+ return nil, nil
+ }
+ return &containers[0], nil
+}
+
// GetContainersHistory returns containers within a time range
func (db *DB) GetContainersHistory(start, end time.Time) ([]models.Container, error) {
query := `
diff --git a/internal/storage/plugins.go b/internal/storage/plugins.go
new file mode 100644
index 0000000..4e53d1b
--- /dev/null
+++ b/internal/storage/plugins.go
@@ -0,0 +1,314 @@
+package storage
+
+import (
+ "database/sql"
+ "time"
+)
+
+// PluginRecord represents a plugin in the database
+type PluginRecord struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Version string `json:"version"`
+ SourceType string `json:"source_type"`
+ SourceURL string `json:"source_url,omitempty"`
+ Enabled bool `json:"enabled"`
+ InstalledAt time.Time `json:"installed_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+// initPluginSchema creates the plugin-related database tables
+func (db *DB) initPluginSchema() error {
+ schema := `
+ -- Plugin registry
+ CREATE TABLE IF NOT EXISTS plugins (
+ id TEXT PRIMARY KEY,
+ name TEXT NOT NULL,
+ version TEXT NOT NULL,
+ source_type TEXT NOT NULL,
+ source_url TEXT,
+ enabled BOOLEAN NOT NULL DEFAULT 1,
+ installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ );
+
+ -- Plugin key-value data storage (scoped per plugin)
+ CREATE TABLE IF NOT EXISTS plugin_data (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plugin_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value BLOB,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(plugin_id, key)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_plugin_data_plugin_id ON plugin_data(plugin_id);
+ CREATE INDEX IF NOT EXISTS idx_plugin_data_key ON plugin_data(plugin_id, key);
+
+ -- Plugin settings (separate from data for UI display)
+ CREATE TABLE IF NOT EXISTS plugin_settings (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ plugin_id TEXT NOT NULL,
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE(plugin_id, key)
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_plugin_settings_plugin_id ON plugin_settings(plugin_id);
+ `
+
+ _, err := db.conn.Exec(schema)
+ return err
+}
+
+// GetPlugin retrieves a plugin by ID
+func (db *DB) GetPlugin(id string) (*PluginRecord, error) {
+ query := `
+ SELECT id, name, version, source_type, COALESCE(source_url, ''), enabled, installed_at, updated_at
+ FROM plugins
+ WHERE id = ?
+ `
+
+ var record PluginRecord
+ err := db.conn.QueryRow(query, id).Scan(
+ &record.ID,
+ &record.Name,
+ &record.Version,
+ &record.SourceType,
+ &record.SourceURL,
+ &record.Enabled,
+ &record.InstalledAt,
+ &record.UpdatedAt,
+ )
+
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return &record, nil
+}
+
+// SavePlugin saves or updates a plugin record
+func (db *DB) SavePlugin(record *PluginRecord) error {
+ query := `
+ INSERT INTO plugins (id, name, version, source_type, source_url, enabled, installed_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ name = excluded.name,
+ version = excluded.version,
+ source_type = excluded.source_type,
+ source_url = excluded.source_url,
+ updated_at = excluded.updated_at
+ `
+
+ _, err := db.conn.Exec(query,
+ record.ID,
+ record.Name,
+ record.Version,
+ record.SourceType,
+ record.SourceURL,
+ record.Enabled,
+ record.InstalledAt,
+ record.UpdatedAt,
+ )
+
+ return err
+}
+
+// GetAllPlugins retrieves all plugin records
+func (db *DB) GetAllPlugins() ([]*PluginRecord, error) {
+ query := `
+ SELECT id, name, version, source_type, COALESCE(source_url, ''), enabled, installed_at, updated_at
+ FROM plugins
+ ORDER BY name
+ `
+
+ rows, err := db.conn.Query(query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var records []*PluginRecord
+ for rows.Next() {
+ var record PluginRecord
+ err := rows.Scan(
+ &record.ID,
+ &record.Name,
+ &record.Version,
+ &record.SourceType,
+ &record.SourceURL,
+ &record.Enabled,
+ &record.InstalledAt,
+ &record.UpdatedAt,
+ )
+ if err != nil {
+ return nil, err
+ }
+ records = append(records, &record)
+ }
+
+ return records, rows.Err()
+}
+
+// SetPluginEnabled enables or disables a plugin
+func (db *DB) SetPluginEnabled(id string, enabled bool) error {
+ query := `UPDATE plugins SET enabled = ?, updated_at = ? WHERE id = ?`
+ _, err := db.conn.Exec(query, enabled, time.Now(), id)
+ return err
+}
+
+// GetPluginData retrieves a plugin data value by key
+func (db *DB) GetPluginData(pluginID, key string) ([]byte, error) {
+ query := `SELECT value FROM plugin_data WHERE plugin_id = ? AND key = ?`
+
+ var value []byte
+ err := db.conn.QueryRow(query, pluginID, key).Scan(&value)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ return value, err
+}
+
+// SetPluginData stores a plugin data value
+func (db *DB) SetPluginData(pluginID, key string, value []byte) error {
+ query := `
+ INSERT INTO plugin_data (plugin_id, key, value, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(plugin_id, key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at
+ `
+
+ _, err := db.conn.Exec(query, pluginID, key, value, time.Now())
+ return err
+}
+
+// DeletePluginData removes a plugin data value
+func (db *DB) DeletePluginData(pluginID, key string) error {
+ query := `DELETE FROM plugin_data WHERE plugin_id = ? AND key = ?`
+ _, err := db.conn.Exec(query, pluginID, key)
+ return err
+}
+
+// ListPluginData returns all data with a key prefix for a plugin
+func (db *DB) ListPluginData(pluginID, prefix string) (map[string][]byte, error) {
+ query := `SELECT key, value FROM plugin_data WHERE plugin_id = ? AND key LIKE ?`
+
+ rows, err := db.conn.Query(query, pluginID, prefix+"%")
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string][]byte)
+ for rows.Next() {
+ var key string
+ var value []byte
+ if err := rows.Scan(&key, &value); err != nil {
+ return nil, err
+ }
+ result[key] = value
+ }
+
+ return result, rows.Err()
+}
+
+// GetPluginSetting retrieves a plugin setting value
+func (db *DB) GetPluginSetting(pluginID, key string) (string, error) {
+ query := `SELECT value FROM plugin_settings WHERE plugin_id = ? AND key = ?`
+
+ var value string
+ err := db.conn.QueryRow(query, pluginID, key).Scan(&value)
+ if err == sql.ErrNoRows {
+ return "", nil
+ }
+ return value, err
+}
+
+// SetPluginSetting stores a plugin setting value
+func (db *DB) SetPluginSetting(pluginID, key, value string) error {
+ query := `
+ INSERT INTO plugin_settings (plugin_id, key, value, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(plugin_id, key) DO UPDATE SET
+ value = excluded.value,
+ updated_at = excluded.updated_at
+ `
+
+ _, err := db.conn.Exec(query, pluginID, key, value, time.Now())
+ return err
+}
+
+// GetAllPluginSettings retrieves all settings for a plugin
+func (db *DB) GetAllPluginSettings(pluginID string) (map[string]string, error) {
+ query := `SELECT key, value FROM plugin_settings WHERE plugin_id = ?`
+
+ rows, err := db.conn.Query(query, pluginID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ result := make(map[string]string)
+ for rows.Next() {
+ var key, value string
+ if err := rows.Scan(&key, &value); err != nil {
+ return nil, err
+ }
+ result[key] = value
+ }
+
+ return result, rows.Err()
+}
+
+// DeleteAllPluginData removes all data for a plugin
+func (db *DB) DeleteAllPluginData(pluginID string) error {
+ tx, err := db.conn.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete settings
+ if _, err := tx.Exec(`DELETE FROM plugin_settings WHERE plugin_id = ?`, pluginID); err != nil {
+ return err
+ }
+
+ // Delete data
+ if _, err := tx.Exec(`DELETE FROM plugin_data WHERE plugin_id = ?`, pluginID); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
+
+// DeletePlugin removes a plugin and all its data
+func (db *DB) DeletePlugin(pluginID string) error {
+ tx, err := db.conn.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+
+ // Delete settings
+ if _, err := tx.Exec(`DELETE FROM plugin_settings WHERE plugin_id = ?`, pluginID); err != nil {
+ return err
+ }
+
+ // Delete data
+ if _, err := tx.Exec(`DELETE FROM plugin_data WHERE plugin_id = ?`, pluginID); err != nil {
+ return err
+ }
+
+ // Delete plugin record
+ if _, err := tx.Exec(`DELETE FROM plugins WHERE id = ?`, pluginID); err != nil {
+ return err
+ }
+
+ return tx.Commit()
+}
diff --git a/web/app.js b/web/app.js
index f19b7bc..3d03deb 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1447,6 +1447,7 @@ function renderCompactCard(cont) {
const stateIcon = isRunning ? 'β
' : isStopped ? 'βΉοΈ' : isPaused ? 'βΈοΈ' : 'β';
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
+ const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
@@ -1461,7 +1462,7 @@ function renderCompactCard(cont) {
π ${escapeHtml(cont.host_name)}
${cont.state}
π·οΈ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}
- β±οΈ ${createdTime}
+ ${uptime ? `β±οΈ ${uptime}` : `π
${createdTime}`}
@@ -1561,6 +1562,7 @@ function renderMaterialCard(cont) {
const stateIcon = isRunning ? 'β
' : isStopped ? 'βΉοΈ' : isPaused ? 'βΈοΈ' : 'β';
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
+ const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
@@ -1574,7 +1576,7 @@ function renderMaterialCard(cont) {
β’
π·οΈ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}
β’
- β±οΈ ${createdTime}
+ ${uptime ? `β±οΈ ${uptime}` : `π
${createdTime}`}
@@ -1686,6 +1688,7 @@ function renderDashboardCard(cont) {
const createdTime = formatDate(cont.created);
const statusText = cont.status || '-';
+ const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return `
@@ -1695,7 +1698,7 @@ function renderDashboardCard(cont) {
${escapeHtml(cont.name)}
${escapeHtml(cont.host_name)}
π·οΈ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}
- ${createdTime}
+ ${uptime ? `β±οΈ ${uptime}` : createdTime}
${cont.update_available ? 'β¬οΈ Update' : ''}