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 = ` +
+

Nginx Proxy Manager Integration

+
+ +
+
+ +
+ +
+

NPM Instances

+
+
Loading instances...
+
+
+ + +
+

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' : ''}
diff --git a/web/index.html b/web/index.html index 1f13b72..9314899 100644 --- a/web/index.html +++ b/web/index.html @@ -1765,6 +1765,7 @@ + diff --git a/web/plugins.js b/web/plugins.js new file mode 100644 index 0000000..f56ec68 --- /dev/null +++ b/web/plugins.js @@ -0,0 +1,428 @@ +// Plugin Manager for Container Census +// Handles loading plugins, rendering tabs, and displaying badges + +class PluginManager { + constructor() { + this.plugins = []; + this.tabs = []; + this.badgeCache = new Map(); // Cache badges by container key + this.initialized = false; + } + + // Initialize plugin system + async init() { + try { + await this.loadPlugins(); + await this.loadTabs(); + this.renderIntegrationsMenu(); + this.initialized = true; + console.log('Plugin system initialized with', this.plugins.length, 'plugins'); + } catch (error) { + console.error('Failed to initialize plugin system:', error); + } + } + + // Load all plugins from API + async loadPlugins() { + try { + const response = await fetchWithAuth('/api/plugins'); + if (response.ok) { + this.plugins = await response.json(); + } else { + console.warn('Failed to load plugins:', response.status); + this.plugins = []; + } + } catch (error) { + console.error('Error loading plugins:', error); + this.plugins = []; + } + } + + // Load all plugin tabs + async loadTabs() { + try { + const response = await fetchWithAuth('/api/plugins/tabs'); + if (response.ok) { + this.tabs = await response.json(); + } else { + console.warn('Failed to load plugin tabs:', response.status); + this.tabs = []; + } + } catch (error) { + console.error('Error loading plugin tabs:', error); + this.tabs = []; + } + } + + // Get badges for a container + async getBadges(hostId, containerId) { + const cacheKey = `${hostId}-${containerId}`; + + // Return from cache if available + if (this.badgeCache.has(cacheKey)) { + return this.badgeCache.get(cacheKey); + } + + try { + const response = await fetchWithAuth(`/api/plugins/badges?host_id=${hostId}&container_id=${containerId}`); + if (response.ok) { + const badges = await response.json(); + this.badgeCache.set(cacheKey, badges); + return badges; + } + } catch (error) { + console.error('Error loading badges:', error); + } + + return []; + } + + // Clear badge cache (call after data refresh) + clearBadgeCache() { + this.badgeCache.clear(); + } + + // Render integrations dropdown in navigation + renderIntegrationsMenu() { + const navContainer = document.querySelector('.sidebar-nav'); + if (!navContainer) { + console.warn('Navigation container not found'); + return; + } + + // Remove existing integrations menu if present + const existing = document.getElementById('integrationsDropdown'); + if (existing) { + existing.remove(); + } + + // Only show if there are tabs from plugins + if (this.tabs.length === 0) { + return; + } + + // Create dropdown HTML + const dropdown = document.createElement('div'); + dropdown.id = 'integrationsDropdown'; + dropdown.className = 'nav-dropdown'; + + dropdown.innerHTML = ` + + + `; + + // Find the position to insert (before Settings/last items) + const notificationsBtn = navContainer.querySelector('[data-tab="notifications"]'); + if (notificationsBtn) { + navContainer.insertBefore(dropdown, notificationsBtn); + } else { + navContainer.appendChild(dropdown); + } + } + + // Toggle integrations dropdown + toggleIntegrationsMenu() { + const dropdown = document.getElementById('integrationsDropdown'); + if (dropdown) { + dropdown.classList.toggle('open'); + } + } + + // Show a plugin tab + async showPluginTab(pluginId) { + // Close dropdown + const dropdown = document.getElementById('integrationsDropdown'); + if (dropdown) { + dropdown.classList.remove('open'); + } + + // Update active states + document.querySelectorAll('.nav-item').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.nav-dropdown-item').forEach(btn => btn.classList.remove('active')); + + const activeBtn = document.querySelector(`[data-plugin-tab="${pluginId}"]`); + if (activeBtn) { + activeBtn.classList.add('active'); + } + + // Find the tab definition + const tab = this.tabs.find(t => t.id === pluginId); + if (!tab) { + console.error('Plugin tab not found:', pluginId); + return; + } + + // Hide all existing tab contents + document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active')); + + // Get or create plugin tab container + let tabContent = document.getElementById(`plugin-tab-${pluginId}`); + if (!tabContent) { + tabContent = document.createElement('div'); + tabContent.id = `plugin-tab-${pluginId}`; + tabContent.className = 'tab-content'; + document.querySelector('.main-content').appendChild(tabContent); + } + + tabContent.classList.add('active'); + + // Load tab content + try { + tabContent.innerHTML = '
Loading...
'; + + // Fetch tab content from plugin API + // Plugin routes are under /api/p/{pluginId}/ to avoid conflict with /api/plugins/{id} management routes + const response = await fetchWithAuth(`/api/p/${pluginId}/tab`); + if (response.ok) { + const html = await response.text(); + tabContent.innerHTML = html; + + // Load external script if specified, then call init function + if (tab.script_url) { + await this.loadPluginScript(tab.script_url, tab.init_func); + } else { + // Execute any inline scripts in the loaded content + this.executeScripts(tabContent); + } + } else { + tabContent.innerHTML = `
Failed to load plugin tab: ${response.status}
`; + } + } catch (error) { + console.error('Error loading plugin tab:', error); + tabContent.innerHTML = `
Error loading plugin: ${error.message}
`; + } + + // Update URL hash + window.location.hash = `/plugin/${pluginId}`; + } + + // Load an external plugin script and call its init function + async loadPluginScript(scriptUrl, initFunc) { + return new Promise((resolve, reject) => { + // Check if script is already loaded + const existingScript = document.querySelector(`script[src="${scriptUrl}"]`); + if (existingScript) { + // Script already loaded, just call init function + if (initFunc && typeof window[initFunc] === 'function') { + try { + window[initFunc](); + } catch (error) { + console.error('Error calling plugin init function:', error); + } + } + resolve(); + return; + } + + // Load the script + const script = document.createElement('script'); + script.src = scriptUrl; + script.onload = () => { + console.log('Plugin script loaded:', scriptUrl); + // Call init function if specified + if (initFunc && typeof window[initFunc] === 'function') { + try { + window[initFunc](); + } catch (error) { + console.error('Error calling plugin init function:', error); + } + } + resolve(); + }; + script.onerror = (error) => { + console.error('Failed to load plugin script:', scriptUrl, error); + reject(error); + }; + document.head.appendChild(script); + }); + } + + // Execute scripts in loaded content + executeScripts(container) { + const scripts = container.querySelectorAll('script'); + scripts.forEach(script => { + // Remove the original script tag first to prevent double execution + script.remove(); + + if (script.src) { + // External scripts - load asynchronously + const newScript = document.createElement('script'); + newScript.src = script.src; + document.head.appendChild(newScript); + } else { + // Inline scripts - execute synchronously using eval in global scope + // This ensures functions assigned to window are available for onclick handlers + try { + // Use indirect eval to execute in global scope + const globalEval = eval; + globalEval(script.textContent); + } catch (error) { + console.error('Error executing plugin script:', error); + } + } + }); + } + + // Render badges for a container element + renderBadges(badges) { + if (!badges || badges.length === 0) { + return ''; + } + + return badges.map(badge => { + const colorClass = badge.color ? `badge-${badge.color}` : ''; + const clickHandler = badge.click_url ? `onclick="window.open('${escapeAttr(badge.click_url)}', '_blank')"` : ''; + const cursorStyle = badge.click_url ? 'cursor: pointer;' : ''; + + return ` + + ${badge.icon ? `${badge.icon}` : ''} + ${escapeHtml(badge.label)} + + `; + }).join(''); + } + + // Get plugin settings + async getPluginSettings(pluginId) { + try { + const response = await fetchWithAuth(`/api/plugins/${pluginId}/settings`); + if (response.ok) { + return await response.json(); + } + } catch (error) { + console.error('Error loading plugin settings:', error); + } + return {}; + } + + // Save plugin settings + async savePluginSettings(pluginId, settings) { + try { + const response = await fetchWithAuth(`/api/plugins/${pluginId}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + return response.ok; + } catch (error) { + console.error('Error saving plugin settings:', error); + return false; + } + } + + // Enable a plugin + async enablePlugin(pluginId) { + try { + const response = await fetchWithAuth(`/api/plugins/${pluginId}/enable`, { + method: 'PUT' + }); + if (response.ok) { + await this.loadPlugins(); + await this.loadTabs(); + this.renderIntegrationsMenu(); + return true; + } + } catch (error) { + console.error('Error enabling plugin:', error); + } + return false; + } + + // Disable a plugin + async disablePlugin(pluginId) { + try { + const response = await fetchWithAuth(`/api/plugins/${pluginId}/disable`, { + method: 'PUT' + }); + if (response.ok) { + await this.loadPlugins(); + await this.loadTabs(); + this.renderIntegrationsMenu(); + return true; + } + } catch (error) { + console.error('Error disabling plugin:', error); + } + return false; + } + + // Get all plugins info + getPlugins() { + return this.plugins; + } + + // Check if a plugin is enabled + isPluginEnabled(pluginId) { + const plugin = this.plugins.find(p => p.id === pluginId); + return plugin ? plugin.enabled !== false : false; + } +} + +// Utility: Format uptime from started_at timestamp +function formatUptime(startedAt) { + if (!startedAt) return ''; + + const now = new Date(); + const started = new Date(startedAt); + + // Check for invalid date + if (isNaN(started.getTime()) || started.getFullYear() < 2000) { + return ''; + } + + const diff = now - started; + if (diff < 0) return ''; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + const remainingHours = hours % 24; + return `${days}d ${remainingHours}h`; + } + if (hours > 0) { + const remainingMins = minutes % 60; + return `${hours}h ${remainingMins}m`; + } + if (minutes > 0) { + return `${minutes}m`; + } + return `${seconds}s`; +} + +// Global plugin manager instance +let pluginManager = new PluginManager(); + +// Initialize when DOM is ready (after app.js initialization) +document.addEventListener('DOMContentLoaded', () => { + // Delay initialization to ensure app.js has loaded + setTimeout(() => { + pluginManager.init(); + }, 100); +}); + +// Handle plugin tab routing +window.addEventListener('hashchange', () => { + const hash = window.location.hash.slice(1); + if (hash && hash.startsWith('/plugin/')) { + const pluginId = hash.replace('/plugin/', ''); + pluginManager.showPluginTab(pluginId); + } +}); diff --git a/web/plugins/npm.js b/web/plugins/npm.js new file mode 100644 index 0000000..36c29b5 --- /dev/null +++ b/web/plugins/npm.js @@ -0,0 +1,275 @@ +// NPM Plugin JavaScript +// This file is loaded by the plugin system when the NPM tab is shown + +(function() { + // State + var npmInstances = []; + var npmExposed = []; + + async function npmLoadData() { + console.log('NPM: Loading data...'); + try { + // Load instances + var instResp = await fetchWithAuth('/api/p/npm/instances'); + console.log('NPM: Instances response:', instResp.status); + if (instResp.ok) { + npmInstances = await instResp.json(); + console.log('NPM: Loaded instances:', npmInstances); + npmRenderInstances(); + } else { + console.error('NPM: Failed to load instances:', instResp.status); + document.getElementById('npmInstances').innerHTML = '
Failed to load instances: ' + instResp.status + '
'; + } + + // Load exposed services + var expResp = await fetchWithAuth('/api/p/npm/exposed'); + console.log('NPM: Exposed response:', expResp.status); + if (expResp.ok) { + npmExposed = await expResp.json(); + console.log('NPM: Loaded exposed:', npmExposed); + npmRenderExposed(); + } else { + console.error('NPM: Failed to load exposed:', expResp.status); + document.getElementById('npmExposed').innerHTML = '
Failed to load exposed services: ' + expResp.status + '
'; + } + } catch (error) { + console.error('NPM: Failed to load data:', error); + document.getElementById('npmInstances').innerHTML = '
Error: ' + error.message + '
'; + } + } + + function npmRenderInstances() { + var container = document.getElementById('npmInstances'); + if (!container) return; + + if (npmInstances.length === 0) { + container.innerHTML = '
No NPM instances configured. Click "Add Instance" to get started.
'; + return; + } + + container.innerHTML = npmInstances.map(function(inst) { + var statusClass = inst.last_error ? 'error' : 'success'; + var statusText = inst.last_error ? 'Error' : 'Connected'; + var lastSync = inst.last_sync ? new Date(inst.last_sync).toLocaleString() : 'Never'; + + return '
' + + '
' + + '

' + escapeHtml(inst.name) + '

' + + '' + statusText + '' + + '
' + + '
' + + '
URL: ' + escapeHtml(inst.url) + '
' + + '
Email: ' + escapeHtml(inst.email) + '
' + + '
Last Sync: ' + lastSync + '
' + + (inst.last_error ? '
Error: ' + escapeHtml(inst.last_error) + '
' : '') + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
'; + }).join(''); + } + + function npmRenderExposed() { + var container = document.getElementById('npmExposed'); + if (!container) return; + + if (npmExposed.length === 0) { + container.innerHTML = '
No exposed services found. Make sure your NPM instances are configured and synced.
'; + return; + } + + var html = '' + + '' + + ''; + + for (var i = 0; i < npmExposed.length; i++) { + var exp = npmExposed[i]; + for (var j = 0; j < exp.mappings.length; j++) { + var mapping = exp.mappings[j]; + var domains = mapping.domain_names || []; + var primaryDomain = domains[0] || 'N/A'; + var scheme = mapping.ssl_enabled ? 'https' : 'http'; + + html += '' + + '' + + '' + + '' + + '' + + '' + + ''; + } + } + + html += '
DomainSSLContainerInstanceActions
' + escapeHtml(primaryDomain) + '' + (mapping.ssl_enabled ? 'Yes' : 'No') + '' + escapeHtml(exp.container_key) + '' + escapeHtml(mapping.instance_name) + 'Open
'; + container.innerHTML = html; + } + + function npmShowAddInstance() { + document.getElementById('npmModalTitle').textContent = 'Add NPM Instance'; + document.getElementById('npmInstanceId').value = ''; + document.getElementById('npmInstanceName').value = ''; + document.getElementById('npmInstanceUrl').value = ''; + document.getElementById('npmInstanceEmail').value = ''; + document.getElementById('npmInstancePassword').value = ''; + document.getElementById('npmInstanceModal').style.display = 'flex'; + } + + function npmEditInstance(id) { + var inst = npmInstances.find(function(i) { return i.id === id; }); + if (!inst) return; + + document.getElementById('npmModalTitle').textContent = 'Edit NPM Instance'; + document.getElementById('npmInstanceId').value = inst.id; + document.getElementById('npmInstanceName').value = inst.name; + document.getElementById('npmInstanceUrl').value = inst.url; + document.getElementById('npmInstanceEmail').value = inst.email; + document.getElementById('npmInstancePassword').value = ''; + document.getElementById('npmInstanceModal').style.display = 'flex'; + } + + function npmCloseModal() { + document.getElementById('npmInstanceModal').style.display = 'none'; + } + + async function npmSaveInstance(event) { + event.preventDefault(); + + var id = document.getElementById('npmInstanceId').value; + var data = { + name: document.getElementById('npmInstanceName').value, + url: document.getElementById('npmInstanceUrl').value, + email: document.getElementById('npmInstanceEmail').value, + password: document.getElementById('npmInstancePassword').value + }; + + try { + var url = id ? '/api/p/npm/instances/' + id : '/api/p/npm/instances'; + var method = id ? 'PUT' : 'POST'; + + var resp = await fetchWithAuth(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (resp.ok) { + npmCloseModal(); + npmLoadData(); + showNotification('Instance saved successfully', 'success'); + } else { + var error = await resp.text(); + showNotification('Failed to save instance: ' + error, 'error'); + } + } catch (error) { + showNotification('Failed to save instance: ' + error.message, 'error'); + } + } + + async function npmDeleteInstance(id) { + if (!confirm('Are you sure you want to delete this NPM instance?')) return; + + try { + var resp = await fetchWithAuth('/api/p/npm/instances/' + id, { method: 'DELETE' }); + if (resp.ok) { + npmLoadData(); + showNotification('Instance deleted', 'success'); + } else { + showNotification('Failed to delete instance', 'error'); + } + } catch (error) { + showNotification('Failed to delete instance: ' + error.message, 'error'); + } + } + + async function npmTestInstance(id) { + try { + var resp = await fetchWithAuth('/api/p/npm/instances/' + id + '/test', { method: 'POST' }); + var result = await resp.json(); + + if (result.success) { + showNotification('Connection successful!', 'success'); + } else { + showNotification('Connection failed: ' + result.error, 'error'); + } + } catch (error) { + showNotification('Test failed: ' + error.message, 'error'); + } + } + + async function npmSyncInstance(id) { + try { + var resp = await fetchWithAuth('/api/p/npm/instances/' + id + '/sync', { method: 'POST' }); + var result = await resp.json(); + + if (result.success) { + showNotification('Synced ' + result.host_count + ' proxy hosts', 'success'); + npmLoadData(); + } else { + showNotification('Sync failed: ' + result.error, 'error'); + } + } catch (error) { + showNotification('Sync failed: ' + error.message, 'error'); + } + } + + // Initialize function called by plugin system + function npmInit() { + console.log('NPM: Initializing...'); + + // Set up event listeners for static elements + var addBtn = document.getElementById('npmAddInstanceBtn'); + if (addBtn) { + addBtn.addEventListener('click', npmShowAddInstance); + } + + var closeBtn = document.getElementById('npmCloseModalBtn'); + if (closeBtn) { + closeBtn.addEventListener('click', npmCloseModal); + } + + var cancelBtn = document.getElementById('npmCancelBtn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', npmCloseModal); + } + + var form = document.getElementById('npmInstanceForm'); + if (form) { + form.addEventListener('submit', npmSaveInstance); + } + + // Event delegation for dynamically created buttons + var instancesContainer = document.getElementById('npmInstances'); + if (instancesContainer) { + instancesContainer.addEventListener('click', function(e) { + var btn = e.target.closest('button'); + if (!btn) return; + + var id = parseInt(btn.dataset.id, 10); + if (btn.classList.contains('npm-sync-btn')) { + npmSyncInstance(id); + } else if (btn.classList.contains('npm-test-btn')) { + npmTestInstance(id); + } else if (btn.classList.contains('npm-edit-btn')) { + npmEditInstance(id); + } else if (btn.classList.contains('npm-delete-btn')) { + npmDeleteInstance(id); + } + }); + } + + // Load data + npmLoadData(); + } + + // Expose init function globally so plugin system can call it + window.npmPluginInit = npmInit; + + // Auto-initialize if DOM elements are already present + if (document.getElementById('npmAddInstanceBtn')) { + npmInit(); + } +})(); diff --git a/web/styles.css b/web/styles.css index d03ec8f..2075c55 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2671,6 +2671,137 @@ tbody tr:hover { opacity: 0.6; } +/* Integrations Dropdown */ +.nav-dropdown { + position: relative; +} + +.nav-dropdown-toggle { + width: 100%; +} + +.nav-dropdown-arrow { + margin-left: auto; + font-size: 0.7rem; + transition: transform 0.2s ease; +} + +.nav-dropdown.open .nav-dropdown-arrow { + transform: rotate(90deg); +} + +.nav-dropdown-content { + display: none; + padding-left: 20px; + flex-direction: column; + gap: 2px; + margin-top: 2px; +} + +.nav-dropdown.open .nav-dropdown-content { + display: flex; +} + +.nav-dropdown-item { + background: transparent; + border: none; + color: white; + padding: 10px 15px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + font-size: 0.9rem; + transition: all 0.2s ease; + text-align: left; + opacity: 0.85; +} + +.nav-dropdown-item:hover { + background: rgba(255, 255, 255, 0.12); + opacity: 1; +} + +.nav-dropdown-item.active { + background: rgba(255, 255, 255, 0.2); + opacity: 1; + font-weight: 500; +} + +/* Plugin Badges */ +.plugin-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 500; + background: var(--bg-tertiary); + color: var(--text-secondary); + transition: all 0.2s ease; +} + +.plugin-badge:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.plugin-badge .badge-icon { + font-size: 0.85rem; +} + +.plugin-badge.badge-info { + background: rgba(59, 130, 246, 0.15); + color: var(--info); +} + +.plugin-badge.badge-success { + background: rgba(16, 185, 129, 0.15); + color: var(--success); +} + +.plugin-badge.badge-warning { + background: rgba(245, 158, 11, 0.15); + color: var(--warning); +} + +.plugin-badge.badge-danger { + background: rgba(239, 68, 68, 0.15); + color: var(--danger); +} + +.plugin-badge.badge-purple { + background: rgba(124, 58, 237, 0.15); + color: var(--primary); +} + +/* Plugin Badges Container */ +.plugin-badges-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +/* Container Uptime Display */ +.container-uptime { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.8rem; + color: var(--text-tertiary); +} + +.container-uptime .uptime-icon { + font-size: 0.9rem; +} + +.container-uptime.running { + color: var(--success); +} + /* Sidebar Actions */ .sidebar-actions { padding: 15px; @@ -6504,6 +6635,11 @@ header { font-size: 0.75rem; } +.theme-compact .chip-uptime { + background: #d4edda; + color: #155724; +} + .theme-compact .metro-actions { display: flex; gap: 6px;