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