Add plugin architecture and NPM integration (WIP)

Plugin system infrastructure:
- Plugin interface with lifecycle management (Init, Start, Stop)
- Plugin manager for registration and route mounting
- Scoped database access for plugin data/settings
- Event bus for plugin communication
- Badge providers and container enrichers

NPM plugin (Nginx Proxy Manager):
- API client with JWT authentication
- Instance management (add/edit/delete/test/sync)
- Proxy host fetching and container matching
- Badge provider for exposed containers
- Tab UI with external JS loading

Container model updates:
- Added NetworkDetails (IP, aliases) for plugin matching
- Added StartedAt timestamp for uptime display
- Added PluginData map for plugin enrichment

Frontend plugin system:
- Plugin manager JS for loading tabs and badges
- Integrations dropdown in navigation
- External script loading with init function callbacks
- Container uptime display on cards

Note: Plugin tab JS execution has issues - Next.js migration planned.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Self Hosters
2025-12-02 16:02:53 -05:00
parent 8dde58ba88
commit 8ac9ca8947
19 changed files with 3542 additions and 8 deletions

View File

@@ -1 +1 @@
1.8.0 1.8.2

View File

@@ -18,6 +18,8 @@ import (
"github.com/container-census/container-census/internal/migration" "github.com/container-census/container-census/internal/migration"
"github.com/container-census/container-census/internal/models" "github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications" "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/registry"
"github.com/container-census/container-census/internal/scanner" "github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage" "github.com/container-census/container-census/internal/storage"
@@ -236,6 +238,25 @@ func main() {
// Store API server reference for hot-reload // Store API server reference for hot-reload
services.apiServer = apiServer 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{ server := &http.Server{
Addr: addr, Addr: addr,
Handler: apiServer.Router(), 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)
}

View File

@@ -228,18 +228,34 @@ func (a *Agent) handleListContainers(w http.ResponseWriter, r *http.Request) {
// Inspect container for detailed connection info // Inspect container for detailed connection info
var restartCount int var restartCount int
var networks []string var networks []string
var networkDetails []models.NetworkDetail
var volumes []models.VolumeMount var volumes []models.VolumeMount
var links []string var links []string
var composeProject string var composeProject string
var startedAt time.Time
containerJSON, err := a.dockerClient.ContainerInspect(ctx, c.ID) containerJSON, err := a.dockerClient.ContainerInspect(ctx, c.ID)
if err == nil { if err == nil {
restartCount = containerJSON.RestartCount 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 { 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) 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), Created: time.Unix(c.Created, 0),
ScannedAt: now, ScannedAt: now,
Networks: networks, Networks: networks,
NetworkDetails: networkDetails,
Volumes: volumes, Volumes: volumes,
Links: links, Links: links,
ComposeProject: composeProject, ComposeProject: composeProject,
StartedAt: startedAt,
} }
result = append(result, container) result = append(result, container)

View File

@@ -15,6 +15,7 @@ import (
"github.com/container-census/container-census/internal/auth" "github.com/container-census/container-census/internal/auth"
"github.com/container-census/container-census/internal/models" "github.com/container-census/container-census/internal/models"
"github.com/container-census/container-census/internal/notifications" "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/registry"
"github.com/container-census/container-census/internal/scanner" "github.com/container-census/container-census/internal/scanner"
"github.com/container-census/container-census/internal/storage" "github.com/container-census/container-census/internal/storage"
@@ -40,6 +41,8 @@ type Server struct {
notificationService *notifications.NotificationService notificationService *notifications.NotificationService
vulnScanner VulnerabilityScanner vulnScanner VulnerabilityScanner
vulnScheduler VulnerabilityScheduler vulnScheduler VulnerabilityScheduler
pluginManager *plugins.Manager
apiRouter *mux.Router // Subrouter for /api with auth middleware
} }
// TelemetryScheduler interface for submitting telemetry on demand // TelemetryScheduler interface for submitting telemetry on demand
@@ -171,6 +174,7 @@ func (s *Server) setupRoutes() {
// Protected API routes // Protected API routes
api := s.router.PathPrefix("/api").Subrouter() api := s.router.PathPrefix("/api").Subrouter()
api.Use(sessionMiddleware) api.Use(sessionMiddleware)
s.apiRouter = api // Store for plugin route mounting
// Host endpoints // Host endpoints
api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET") api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET")
@@ -291,6 +295,9 @@ func (s *Server) setupRoutes() {
// Changelog endpoint // Changelog endpoint
api.HandleFunc("/changelog", s.handleGetChangelog).Methods("GET") api.HandleFunc("/changelog", s.handleGetChangelog).Methods("GET")
// Plugin endpoints
s.setupPluginRoutes(api)
// Serve static files with selective authentication // Serve static files with selective authentication
// Login pages are public, everything else requires auth // Login pages are public, everything else requires auth
// Add cache control headers for JS files to ensure updates are seen // Add cache control headers for JS files to ensure updates are seen

282
internal/api/plugins.go Normal file
View File

@@ -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",
})
}

View File

@@ -53,6 +53,20 @@ type Container struct {
// Image update tracking // Image update tracking
UpdateAvailable bool `json:"update_available"` UpdateAvailable bool `json:"update_available"`
LastUpdateCheck time.Time `json:"last_update_check,omitempty"` 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 // PortMapping represents a container port mapping

View File

@@ -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
}

View File

@@ -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 = `
<div class="tab-header">
<h2>Nginx Proxy Manager Integration</h2>
<div class="tab-actions">
<button class="btn btn-primary" id="npmAddInstanceBtn">+ Add Instance</button>
</div>
</div>
<div class="npm-content">
<!-- Instances Section -->
<div class="section">
<h3>NPM Instances</h3>
<div id="npmInstances" class="npm-instances-grid">
<div class="loading">Loading instances...</div>
</div>
</div>
<!-- Exposed Services Section -->
<div class="section">
<h3>Exposed Services</h3>
<div id="npmExposed" class="npm-exposed-table">
<div class="loading">Loading exposed services...</div>
</div>
</div>
</div>
<!-- Add/Edit Instance Modal -->
<div id="npmInstanceModal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="npmModalTitle">Add NPM Instance</h3>
<button class="modal-close" id="npmCloseModalBtn">&times;</button>
</div>
<form id="npmInstanceForm">
<input type="hidden" id="npmInstanceId">
<div class="form-group">
<label for="npmInstanceName">Name</label>
<input type="text" id="npmInstanceName" required placeholder="My NPM">
</div>
<div class="form-group">
<label for="npmInstanceUrl">URL</label>
<input type="url" id="npmInstanceUrl" required placeholder="http://npm.local:81">
</div>
<div class="form-group">
<label for="npmInstanceEmail">Email</label>
<input type="email" id="npmInstanceEmail" required placeholder="admin@example.com">
</div>
<div class="form-group">
<label for="npmInstancePassword">Password</label>
<input type="password" id="npmInstancePassword" placeholder="Leave blank to keep existing">
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="npmCancelBtn">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
<style>
.npm-content {
padding: 20px;
}
.npm-instances-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
}
.npm-instance-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.npm-instance-card .instance-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.npm-instance-card .instance-header h4 {
margin: 0;
font-size: 1.1rem;
}
.npm-instance-card .instance-details {
font-size: 0.9rem;
color: var(--text-secondary);
}
.npm-instance-card .instance-details .detail {
margin: 4px 0;
}
.npm-instance-card .instance-details .label {
font-weight: 500;
color: var(--text-primary);
}
.npm-instance-card .instance-details .error {
color: var(--danger);
}
.npm-instance-card .instance-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.status-badge {
padding: 4px 8px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.status-badge.success {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
.status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: var(--danger);
}
.npm-exposed-table {
overflow-x: auto;
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-tertiary);
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
background: var(--bg-tertiary);
}
.badge.success {
background: rgba(16, 185, 129, 0.15);
color: var(--success);
}
</style>
`

View File

@@ -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,
},
}
}

View File

@@ -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)
}

455
internal/plugins/manager.go Normal file
View File

@@ -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)
}

View File

@@ -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.) // Inspect container for detailed info (restart count, connections, etc.)
var restartCount int var restartCount int
var networks []string var networks []string
var networkDetails []models.NetworkDetail
var volumes []models.VolumeMount var volumes []models.VolumeMount
var links []string var links []string
var composeProject string var composeProject string
var startedAt time.Time
containerJSON, err := dockerClient.ContainerInspect(ctx, c.ID) containerJSON, err := dockerClient.ContainerInspect(ctx, c.ID)
if err == nil { if err == nil {
restartCount = containerJSON.RestartCount 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 { 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) 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, HostName: host.Name,
ScannedAt: now, ScannedAt: now,
Networks: networks, Networks: networks,
NetworkDetails: networkDetails,
Volumes: volumes, Volumes: volumes,
Links: links, Links: links,
ComposeProject: composeProject, ComposeProject: composeProject,
StartedAt: startedAt,
} }
result = append(result, container) result = append(result, container)

View File

@@ -370,6 +370,11 @@ func (db *DB) initSchema() error {
return err return err
} }
// Initialize plugin schema
if err := db.initPluginSchema(); err != nil {
return err
}
// Run migrations for existing databases // Run migrations for existing databases
return db.runMigrations() return db.runMigrations()
} }
@@ -845,6 +850,39 @@ func (db *DB) GetContainersByHost(hostID int64) ([]models.Container, error) {
return db.scanContainers(rows) 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 // GetContainersHistory returns containers within a time range
func (db *DB) GetContainersHistory(start, end time.Time) ([]models.Container, error) { func (db *DB) GetContainersHistory(start, end time.Time) ([]models.Container, error) {
query := ` query := `

314
internal/storage/plugins.go Normal file
View File

@@ -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()
}

View File

@@ -1447,6 +1447,7 @@ function renderCompactCard(cont) {
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓'; const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
const createdTime = formatDate(cont.created); const createdTime = formatDate(cont.created);
const statusText = cont.status || '-'; const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return ` return `
<div class="container-card-modern theme-compact ${cont.state}"> <div class="container-card-modern theme-compact ${cont.state}">
@@ -1461,7 +1462,7 @@ function renderCompactCard(cont) {
<span class="chip chip-host">📍 ${escapeHtml(cont.host_name)}</span> <span class="chip chip-host">📍 ${escapeHtml(cont.host_name)}</span>
<span class="chip chip-state ${cont.state}">${cont.state}</span> <span class="chip chip-state ${cont.state}">${cont.state}</span>
<span class="chip chip-image" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span> <span class="chip chip-image" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
<span class="chip chip-time">⏱️ ${createdTime}</span> ${uptime ? `<span class="chip chip-uptime" title="Uptime">⏱️ ${uptime}</span>` : `<span class="chip chip-time">📅 ${createdTime}</span>`}
</div> </div>
</div> </div>
</div> </div>
@@ -1561,6 +1562,7 @@ function renderMaterialCard(cont) {
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓'; const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
const createdTime = formatDate(cont.created); const createdTime = formatDate(cont.created);
const statusText = cont.status || '-'; const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return ` return `
<div class="container-card-modern theme-material ${cont.state}"> <div class="container-card-modern theme-material ${cont.state}">
@@ -1574,7 +1576,7 @@ function renderMaterialCard(cont) {
<span class="material-meta-separator">•</span> <span class="material-meta-separator">•</span>
<span class="material-meta-item" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span> <span class="material-meta-item" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
<span class="material-meta-separator">•</span> <span class="material-meta-separator">•</span>
<span class="material-meta-item">⏱️ ${createdTime}</span> <span class="material-meta-item">${uptime ? `⏱️ ${uptime}` : `📅 ${createdTime}`}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1686,6 +1688,7 @@ function renderDashboardCard(cont) {
const createdTime = formatDate(cont.created); const createdTime = formatDate(cont.created);
const statusText = cont.status || '-'; const statusText = cont.status || '-';
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
return ` return `
<div class="container-card-modern theme-dashboard ${cont.state}"> <div class="container-card-modern theme-dashboard ${cont.state}">
@@ -1695,7 +1698,7 @@ function renderDashboardCard(cont) {
<h3 class="dashboard-name">${escapeHtml(cont.name)}</h3> <h3 class="dashboard-name">${escapeHtml(cont.name)}</h3>
<span class="dashboard-tag">${escapeHtml(cont.host_name)}</span> <span class="dashboard-tag">${escapeHtml(cont.host_name)}</span>
<span class="dashboard-tag" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span> <span class="dashboard-tag" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</span>
<span class="dashboard-tag time">${createdTime}</span> <span class="dashboard-tag time">${uptime ? `⏱️ ${uptime}` : createdTime}</span>
${cont.update_available ? '<span class="dashboard-tag alert">⬆️ Update</span>' : ''} ${cont.update_available ? '<span class="dashboard-tag alert">⬆️ Update</span>' : ''}
</div> </div>
<div class="dashboard-actions-menu"> <div class="dashboard-actions-menu">

View File

@@ -1765,6 +1765,7 @@
<script src="notifications.js?v=5"></script> <script src="notifications.js?v=5"></script>
<script src="onboarding.js?v=1"></script> <script src="onboarding.js?v=1"></script>
<script src="plugins.js?v=1"></script>
<script src="app.js?v=17"></script> <script src="app.js?v=17"></script>
</body> </body>
</html> </html>

428
web/plugins.js Normal file
View File

@@ -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 = `
<button class="nav-item nav-dropdown-toggle" onclick="pluginManager.toggleIntegrationsMenu()">
<span class="nav-icon">🔌</span>
<span class="nav-label">Integrations</span>
<span class="nav-dropdown-arrow">▸</span>
</button>
<div class="nav-dropdown-content" id="integrationsSubmenu">
${this.tabs.map(tab => `
<button class="nav-dropdown-item" data-plugin-tab="${tab.id}" onclick="pluginManager.showPluginTab('${tab.id}')">
<span class="nav-icon">${tab.icon || '📦'}</span>
<span class="nav-label">${escapeHtml(tab.label)}</span>
</button>
`).join('')}
</div>
`;
// 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 = '<div class="loading-spinner">Loading...</div>';
// 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 = `<div class="error-message">Failed to load plugin tab: ${response.status}</div>`;
}
} catch (error) {
console.error('Error loading plugin tab:', error);
tabContent.innerHTML = `<div class="error-message">Error loading plugin: ${error.message}</div>`;
}
// 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 `
<span class="plugin-badge ${colorClass}"
title="${escapeAttr(badge.tooltip || '')}"
style="${cursorStyle}"
${clickHandler}>
${badge.icon ? `<span class="badge-icon">${badge.icon}</span>` : ''}
<span class="badge-label">${escapeHtml(badge.label)}</span>
</span>
`;
}).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);
}
});

275
web/plugins/npm.js Normal file
View File

@@ -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 = '<div class="empty-state">Failed to load instances: ' + instResp.status + '</div>';
}
// 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 = '<div class="empty-state">Failed to load exposed services: ' + expResp.status + '</div>';
}
} catch (error) {
console.error('NPM: Failed to load data:', error);
document.getElementById('npmInstances').innerHTML = '<div class="empty-state">Error: ' + error.message + '</div>';
}
}
function npmRenderInstances() {
var container = document.getElementById('npmInstances');
if (!container) return;
if (npmInstances.length === 0) {
container.innerHTML = '<div class="empty-state">No NPM instances configured. Click "Add Instance" to get started.</div>';
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 '<div class="npm-instance-card">' +
'<div class="instance-header">' +
'<h4>' + escapeHtml(inst.name) + '</h4>' +
'<span class="status-badge ' + statusClass + '">' + statusText + '</span>' +
'</div>' +
'<div class="instance-details">' +
'<div class="detail"><span class="label">URL:</span> ' + escapeHtml(inst.url) + '</div>' +
'<div class="detail"><span class="label">Email:</span> ' + escapeHtml(inst.email) + '</div>' +
'<div class="detail"><span class="label">Last Sync:</span> ' + lastSync + '</div>' +
(inst.last_error ? '<div class="detail error"><span class="label">Error:</span> ' + escapeHtml(inst.last_error) + '</div>' : '') +
'</div>' +
'<div class="instance-actions">' +
'<button class="btn btn-sm npm-sync-btn" data-id="' + inst.id + '">Sync</button>' +
'<button class="btn btn-sm npm-test-btn" data-id="' + inst.id + '">Test</button>' +
'<button class="btn btn-sm npm-edit-btn" data-id="' + inst.id + '">Edit</button>' +
'<button class="btn btn-sm btn-danger npm-delete-btn" data-id="' + inst.id + '">Delete</button>' +
'</div>' +
'</div>';
}).join('');
}
function npmRenderExposed() {
var container = document.getElementById('npmExposed');
if (!container) return;
if (npmExposed.length === 0) {
container.innerHTML = '<div class="empty-state">No exposed services found. Make sure your NPM instances are configured and synced.</div>';
return;
}
var html = '<table class="data-table"><thead><tr>' +
'<th>Domain</th><th>SSL</th><th>Container</th><th>Instance</th><th>Actions</th>' +
'</tr></thead><tbody>';
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 += '<tr>' +
'<td><a href="' + scheme + '://' + escapeHtml(primaryDomain) + '" target="_blank">' + escapeHtml(primaryDomain) + '</a></td>' +
'<td>' + (mapping.ssl_enabled ? '<span class="badge success">Yes</span>' : '<span class="badge">No</span>') + '</td>' +
'<td>' + escapeHtml(exp.container_key) + '</td>' +
'<td>' + escapeHtml(mapping.instance_name) + '</td>' +
'<td><a href="' + scheme + '://' + escapeHtml(primaryDomain) + '" target="_blank" class="btn btn-sm">Open</a></td>' +
'</tr>';
}
}
html += '</tbody></table>';
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();
}
})();

View File

@@ -2671,6 +2671,137 @@ tbody tr:hover {
opacity: 0.6; 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 */
.sidebar-actions { .sidebar-actions {
padding: 15px; padding: 15px;
@@ -6504,6 +6635,11 @@ header {
font-size: 0.75rem; font-size: 0.75rem;
} }
.theme-compact .chip-uptime {
background: #d4edda;
color: #155724;
}
.theme-compact .metro-actions { .theme-compact .metro-actions {
display: flex; display: flex;
gap: 6px; gap: 6px;