mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2025-12-21 05:59:44 -06:00
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:
@@ -18,6 +18,8 @@ import (
|
||||
"github.com/container-census/container-census/internal/migration"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/notifications"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/container-census/container-census/internal/plugins/builtin/npm"
|
||||
"github.com/container-census/container-census/internal/registry"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
@@ -236,6 +238,25 @@ func main() {
|
||||
// Store API server reference for hot-reload
|
||||
services.apiServer = apiServer
|
||||
|
||||
// Initialize plugin manager
|
||||
containerProvider := &containerProviderImpl{db: db}
|
||||
hostProvider := &hostProviderImpl{db: db}
|
||||
pluginManager := plugins.NewManager(db, containerProvider, hostProvider)
|
||||
pluginManager.SetRouter(apiServer.GetRouter())
|
||||
apiServer.SetPluginManager(pluginManager)
|
||||
|
||||
// Register built-in plugins
|
||||
npm.Register(pluginManager)
|
||||
|
||||
// Load and start plugins
|
||||
if err := pluginManager.LoadBuiltInPlugins(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to load built-in plugins: %v", err)
|
||||
}
|
||||
if err := pluginManager.Start(context.Background()); err != nil {
|
||||
log.Printf("Warning: Failed to start plugins: %v", err)
|
||||
}
|
||||
log.Println("Plugin manager initialized")
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: apiServer.Router(),
|
||||
@@ -916,3 +937,34 @@ func runImageUpdateChecker(ctx context.Context, db *storage.DB, scan *scanner.Sc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containerProviderImpl implements plugins.ContainerProvider
|
||||
type containerProviderImpl struct {
|
||||
db *storage.DB
|
||||
}
|
||||
|
||||
func (p *containerProviderImpl) GetContainers() []models.Container {
|
||||
containers, err := p.db.GetLatestContainers()
|
||||
if err != nil {
|
||||
log.Printf("Error getting containers for plugin: %v", err)
|
||||
return nil
|
||||
}
|
||||
return containers
|
||||
}
|
||||
|
||||
func (p *containerProviderImpl) GetContainerByID(hostID int64, containerID string) (*models.Container, error) {
|
||||
return p.db.GetContainerByID(hostID, containerID)
|
||||
}
|
||||
|
||||
// hostProviderImpl implements plugins.HostProvider
|
||||
type hostProviderImpl struct {
|
||||
db *storage.DB
|
||||
}
|
||||
|
||||
func (p *hostProviderImpl) GetHosts() ([]models.Host, error) {
|
||||
return p.db.GetHosts()
|
||||
}
|
||||
|
||||
func (p *hostProviderImpl) GetHostByID(id int64) (*models.Host, error) {
|
||||
return p.db.GetHost(id)
|
||||
}
|
||||
|
||||
@@ -228,18 +228,34 @@ func (a *Agent) handleListContainers(w http.ResponseWriter, r *http.Request) {
|
||||
// Inspect container for detailed connection info
|
||||
var restartCount int
|
||||
var networks []string
|
||||
var networkDetails []models.NetworkDetail
|
||||
var volumes []models.VolumeMount
|
||||
var links []string
|
||||
var composeProject string
|
||||
var startedAt time.Time
|
||||
|
||||
containerJSON, err := a.dockerClient.ContainerInspect(ctx, c.ID)
|
||||
if err == nil {
|
||||
restartCount = containerJSON.RestartCount
|
||||
|
||||
// Extract network connections
|
||||
// Extract StartedAt for uptime tracking
|
||||
if containerJSON.State != nil && containerJSON.State.StartedAt != "" {
|
||||
if parsed, parseErr := time.Parse(time.RFC3339Nano, containerJSON.State.StartedAt); parseErr == nil {
|
||||
startedAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Extract network connections with details
|
||||
if containerJSON.NetworkSettings != nil && containerJSON.NetworkSettings.Networks != nil {
|
||||
for networkName := range containerJSON.NetworkSettings.Networks {
|
||||
for networkName, networkSettings := range containerJSON.NetworkSettings.Networks {
|
||||
networks = append(networks, networkName)
|
||||
// Add detailed network info for plugin matching
|
||||
networkDetails = append(networkDetails, models.NetworkDetail{
|
||||
NetworkName: networkName,
|
||||
IPAddress: networkSettings.IPAddress,
|
||||
Gateway: networkSettings.Gateway,
|
||||
Aliases: networkSettings.Aliases,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,9 +304,11 @@ func (a *Agent) handleListContainers(w http.ResponseWriter, r *http.Request) {
|
||||
Created: time.Unix(c.Created, 0),
|
||||
ScannedAt: now,
|
||||
Networks: networks,
|
||||
NetworkDetails: networkDetails,
|
||||
Volumes: volumes,
|
||||
Links: links,
|
||||
ComposeProject: composeProject,
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
|
||||
result = append(result, container)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/container-census/container-census/internal/auth"
|
||||
"github.com/container-census/container-census/internal/models"
|
||||
"github.com/container-census/container-census/internal/notifications"
|
||||
"github.com/container-census/container-census/internal/plugins"
|
||||
"github.com/container-census/container-census/internal/registry"
|
||||
"github.com/container-census/container-census/internal/scanner"
|
||||
"github.com/container-census/container-census/internal/storage"
|
||||
@@ -40,6 +41,8 @@ type Server struct {
|
||||
notificationService *notifications.NotificationService
|
||||
vulnScanner VulnerabilityScanner
|
||||
vulnScheduler VulnerabilityScheduler
|
||||
pluginManager *plugins.Manager
|
||||
apiRouter *mux.Router // Subrouter for /api with auth middleware
|
||||
}
|
||||
|
||||
// TelemetryScheduler interface for submitting telemetry on demand
|
||||
@@ -171,6 +174,7 @@ func (s *Server) setupRoutes() {
|
||||
// Protected API routes
|
||||
api := s.router.PathPrefix("/api").Subrouter()
|
||||
api.Use(sessionMiddleware)
|
||||
s.apiRouter = api // Store for plugin route mounting
|
||||
|
||||
// Host endpoints
|
||||
api.HandleFunc("/hosts", s.handleGetHosts).Methods("GET")
|
||||
@@ -291,6 +295,9 @@ func (s *Server) setupRoutes() {
|
||||
// Changelog endpoint
|
||||
api.HandleFunc("/changelog", s.handleGetChangelog).Methods("GET")
|
||||
|
||||
// Plugin endpoints
|
||||
s.setupPluginRoutes(api)
|
||||
|
||||
// Serve static files with selective authentication
|
||||
// Login pages are public, everything else requires auth
|
||||
// Add cache control headers for JS files to ensure updates are seen
|
||||
|
||||
282
internal/api/plugins.go
Normal file
282
internal/api/plugins.go
Normal 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",
|
||||
})
|
||||
}
|
||||
@@ -53,6 +53,20 @@ type Container struct {
|
||||
// Image update tracking
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
LastUpdateCheck time.Time `json:"last_update_check,omitempty"`
|
||||
// Container uptime tracking
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
// Network details for plugin matching (e.g., NPM integration)
|
||||
NetworkDetails []NetworkDetail `json:"network_details,omitempty"`
|
||||
// Plugin-provided enrichment data
|
||||
PluginData map[string]interface{} `json:"plugin_data,omitempty"`
|
||||
}
|
||||
|
||||
// NetworkDetail contains per-network container connection info
|
||||
type NetworkDetail struct {
|
||||
NetworkName string `json:"network_name"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
}
|
||||
|
||||
// PortMapping represents a container port mapping
|
||||
|
||||
212
internal/plugins/builtin/npm/client.go
Normal file
212
internal/plugins/builtin/npm/client.go
Normal 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
|
||||
}
|
||||
827
internal/plugins/builtin/npm/plugin.go
Normal file
827
internal/plugins/builtin/npm/plugin.go
Normal 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">×</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>
|
||||
`
|
||||
222
internal/plugins/event_bus.go
Normal file
222
internal/plugins/event_bus.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
232
internal/plugins/interface.go
Normal file
232
internal/plugins/interface.go
Normal 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
455
internal/plugins/manager.go
Normal 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)
|
||||
}
|
||||
@@ -131,18 +131,34 @@ func (s *Scanner) ScanHost(ctx context.Context, host models.Host) ([]models.Cont
|
||||
// Inspect container for detailed info (restart count, connections, etc.)
|
||||
var restartCount int
|
||||
var networks []string
|
||||
var networkDetails []models.NetworkDetail
|
||||
var volumes []models.VolumeMount
|
||||
var links []string
|
||||
var composeProject string
|
||||
var startedAt time.Time
|
||||
|
||||
containerJSON, err := dockerClient.ContainerInspect(ctx, c.ID)
|
||||
if err == nil {
|
||||
restartCount = containerJSON.RestartCount
|
||||
|
||||
// Extract network connections
|
||||
// Extract StartedAt for uptime tracking
|
||||
if containerJSON.State != nil && containerJSON.State.StartedAt != "" {
|
||||
if parsed, parseErr := time.Parse(time.RFC3339Nano, containerJSON.State.StartedAt); parseErr == nil {
|
||||
startedAt = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Extract network connections with details
|
||||
if containerJSON.NetworkSettings != nil && containerJSON.NetworkSettings.Networks != nil {
|
||||
for networkName := range containerJSON.NetworkSettings.Networks {
|
||||
for networkName, networkSettings := range containerJSON.NetworkSettings.Networks {
|
||||
networks = append(networks, networkName)
|
||||
// Add detailed network info for plugin matching
|
||||
networkDetails = append(networkDetails, models.NetworkDetail{
|
||||
NetworkName: networkName,
|
||||
IPAddress: networkSettings.IPAddress,
|
||||
Gateway: networkSettings.Gateway,
|
||||
Aliases: networkSettings.Aliases,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,9 +210,11 @@ func (s *Scanner) ScanHost(ctx context.Context, host models.Host) ([]models.Cont
|
||||
HostName: host.Name,
|
||||
ScannedAt: now,
|
||||
Networks: networks,
|
||||
NetworkDetails: networkDetails,
|
||||
Volumes: volumes,
|
||||
Links: links,
|
||||
ComposeProject: composeProject,
|
||||
StartedAt: startedAt,
|
||||
}
|
||||
|
||||
result = append(result, container)
|
||||
|
||||
@@ -370,6 +370,11 @@ func (db *DB) initSchema() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize plugin schema
|
||||
if err := db.initPluginSchema(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run migrations for existing databases
|
||||
return db.runMigrations()
|
||||
}
|
||||
@@ -845,6 +850,39 @@ func (db *DB) GetContainersByHost(hostID int64) ([]models.Container, error) {
|
||||
return db.scanContainers(rows)
|
||||
}
|
||||
|
||||
// GetContainerByID returns a specific container by host ID and container ID
|
||||
func (db *DB) GetContainerByID(hostID int64, containerID string) (*models.Container, error) {
|
||||
query := `
|
||||
SELECT c.id, c.name, c.image, c.image_id, c.image_digest, c.image_tags, c.state, c.status,
|
||||
c.ports, c.labels, c.created, c.host_id, c.host_name, c.scanned_at,
|
||||
c.networks, c.volumes, c.links, c.compose_project,
|
||||
c.cpu_percent, c.memory_usage, c.memory_limit, c.memory_percent,
|
||||
c.update_available, c.last_update_check
|
||||
FROM containers c
|
||||
INNER JOIN (
|
||||
SELECT MAX(scanned_at) as max_scan
|
||||
FROM containers
|
||||
WHERE host_id = ?
|
||||
) latest ON c.scanned_at = latest.max_scan
|
||||
WHERE c.host_id = ? AND c.id = ?
|
||||
`
|
||||
|
||||
rows, err := db.conn.Query(query, hostID, hostID, containerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
containers, err := db.scanContainers(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(containers) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &containers[0], nil
|
||||
}
|
||||
|
||||
// GetContainersHistory returns containers within a time range
|
||||
func (db *DB) GetContainersHistory(start, end time.Time) ([]models.Container, error) {
|
||||
query := `
|
||||
|
||||
314
internal/storage/plugins.go
Normal file
314
internal/storage/plugins.go
Normal 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()
|
||||
}
|
||||
@@ -1447,6 +1447,7 @@ function renderCompactCard(cont) {
|
||||
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
|
||||
const createdTime = formatDate(cont.created);
|
||||
const statusText = cont.status || '-';
|
||||
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
|
||||
|
||||
return `
|
||||
<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-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-time">⏱️ ${createdTime}</span>
|
||||
${uptime ? `<span class="chip chip-uptime" title="Uptime">⏱️ ${uptime}</span>` : `<span class="chip chip-time">📅 ${createdTime}</span>`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1561,6 +1562,7 @@ function renderMaterialCard(cont) {
|
||||
const stateIcon = isRunning ? '✅' : isStopped ? '⏹️' : isPaused ? '⏸️' : '❓';
|
||||
const createdTime = formatDate(cont.created);
|
||||
const statusText = cont.status || '-';
|
||||
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
|
||||
|
||||
return `
|
||||
<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-item" title="${escapeHtml(cont.image)}">🏷️ ${escapeHtml(extractImageTag(cont.image, cont.image_tags))}</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>
|
||||
@@ -1686,6 +1688,7 @@ function renderDashboardCard(cont) {
|
||||
|
||||
const createdTime = formatDate(cont.created);
|
||||
const statusText = cont.status || '-';
|
||||
const uptime = isRunning && cont.started_at ? formatUptime(cont.started_at) : '';
|
||||
|
||||
return `
|
||||
<div class="container-card-modern theme-dashboard ${cont.state}">
|
||||
@@ -1695,7 +1698,7 @@ function renderDashboardCard(cont) {
|
||||
<h3 class="dashboard-name">${escapeHtml(cont.name)}</h3>
|
||||
<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 time">${createdTime}</span>
|
||||
<span class="dashboard-tag time">${uptime ? `⏱️ ${uptime}` : createdTime}</span>
|
||||
${cont.update_available ? '<span class="dashboard-tag alert">⬆️ Update</span>' : ''}
|
||||
</div>
|
||||
<div class="dashboard-actions-menu">
|
||||
|
||||
@@ -1765,6 +1765,7 @@
|
||||
|
||||
<script src="notifications.js?v=5"></script>
|
||||
<script src="onboarding.js?v=1"></script>
|
||||
<script src="plugins.js?v=1"></script>
|
||||
<script src="app.js?v=17"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
428
web/plugins.js
Normal file
428
web/plugins.js
Normal 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
275
web/plugins/npm.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
136
web/styles.css
136
web/styles.css
@@ -2671,6 +2671,137 @@ tbody tr:hover {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Integrations Dropdown */
|
||||
.nav-dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-dropdown-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-dropdown-arrow {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-dropdown.open .nav-dropdown-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.nav-dropdown-content {
|
||||
display: none;
|
||||
padding-left: 20px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.nav-dropdown.open .nav-dropdown-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-dropdown-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s ease;
|
||||
text-align: left;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nav-dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-dropdown-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Plugin Badges */
|
||||
.plugin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.plugin-badge:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.plugin-badge .badge-icon {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.plugin-badge.badge-info {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.plugin-badge.badge-success {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.plugin-badge.badge-warning {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.plugin-badge.badge-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.plugin-badge.badge-purple {
|
||||
background: rgba(124, 58, 237, 0.15);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Plugin Badges Container */
|
||||
.plugin-badges-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Container Uptime Display */
|
||||
.container-uptime {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.container-uptime .uptime-icon {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.container-uptime.running {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
/* Sidebar Actions */
|
||||
.sidebar-actions {
|
||||
padding: 15px;
|
||||
@@ -6504,6 +6635,11 @@ header {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.theme-compact .chip-uptime {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.theme-compact .metro-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
|
||||
Reference in New Issue
Block a user