diff --git a/api/handlers/resources.go b/api/handlers/resources.go index 840c1153..d9671fac 100644 --- a/api/handlers/resources.go +++ b/api/handlers/resources.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "net/http" + "strings" "github.com/gin-gonic/gin" ) @@ -375,6 +376,124 @@ func (h *ResourceHandler) DeleteResource(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Resource deleted successfully"}) } +// DeleteDisabledResources deletes a list of disabled resources (bulk). +func (h *ResourceHandler) DeleteDisabledResources(c *gin.Context) { + var payload struct { + IDs []string `json:"ids" binding:"required"` + } + + if err := c.ShouldBindJSON(&payload); err != nil || len(payload.IDs) == 0 { + ResponseWithError(c, http.StatusBadRequest, "IDs are required") + return + } + + // Use a transaction to remove relationships then resources + tx, err := h.DB.Begin() + if err != nil { + log.Printf("Error beginning transaction: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + var txErr error + defer func() { + if txErr != nil { + tx.Rollback() + log.Printf("Transaction rolled back due to error: %v", txErr) + } + }() + + placeholders := strings.Repeat("?,", len(payload.IDs)) + placeholders = strings.TrimSuffix(placeholders, ",") + + // Ensure all IDs are disabled before deleting + query := fmt.Sprintf("SELECT id, status FROM resources WHERE id IN (%s)", placeholders) + args := make([]interface{}, len(payload.IDs)) + for i, v := range payload.IDs { + args[i] = v + } + + rows, err := tx.Query(query, args...) + if err != nil { + txErr = err + log.Printf("Error checking resource statuses: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + defer rows.Close() + + allowed := map[string]struct{}{} + for rows.Next() { + var rid, status string + if err := rows.Scan(&rid, &status); err != nil { + txErr = err + log.Printf("Error scanning resource row: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + if status == "disabled" { + allowed[rid] = struct{}{} + } + } + + // Filter IDs to disabled ones + disabledIDs := make([]string, 0, len(allowed)) + for _, id := range payload.IDs { + if _, ok := allowed[id]; ok { + disabledIDs = append(disabledIDs, id) + } + } + + if len(disabledIDs) == 0 { + ResponseWithError(c, http.StatusBadRequest, "No disabled resources to delete") + return + } + + // Build placeholders for disabled IDs + dPlaceholders := strings.Repeat("?,", len(disabledIDs)) + dPlaceholders = strings.TrimSuffix(dPlaceholders, ",") + dArgs := make([]interface{}, len(disabledIDs)) + for i, v := range disabledIDs { + dArgs[i] = v + } + + // Delete resource_middlewares first + rmQuery := fmt.Sprintf("DELETE FROM resource_middlewares WHERE resource_id IN (%s)", dPlaceholders) + if _, txErr = tx.Exec(rmQuery, dArgs...); txErr != nil { + log.Printf("Error deleting resource_middlewares: %v", txErr) + ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resources") + return + } + + // Delete resources + resQuery := fmt.Sprintf("DELETE FROM resources WHERE id IN (%s) AND status = 'disabled'", dPlaceholders) + result, txErr := tx.Exec(resQuery, dArgs...) + if txErr != nil { + log.Printf("Error deleting resources: %v", txErr) + ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resources") + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + txErr = err + log.Printf("Error getting rows affected: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + if txErr = tx.Commit(); txErr != nil { + log.Printf("Error committing transaction: %v", txErr) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + + c.JSON(http.StatusOK, gin.H{ + "deleted": rowsAffected, + "ids": disabledIDs, + }) +} + // AssignMiddleware assigns a middleware to a resource func (h *ResourceHandler) AssignMiddleware(c *gin.Context) { resourceID := c.Param("id") diff --git a/api/handlers/services.go b/api/handlers/services.go index a638f036..5511aa86 100644 --- a/api/handlers/services.go +++ b/api/handlers/services.go @@ -6,10 +6,12 @@ import ( "fmt" "log" "net/http" + "strings" "time" "github.com/gin-gonic/gin" "github.com/hhftechnology/middleware-manager/models" + "github.com/hhftechnology/middleware-manager/util" ) // ServiceHandler handles service-related requests @@ -24,13 +26,24 @@ func NewServiceHandler(db *sql.DB) *ServiceHandler { // GetServices returns all service configurations // Supports pagination via ?page=N&page_size=M query parameters +// By default only returns active services; use ?status=all to include disabled func (h *ServiceHandler) GetServices(c *gin.Context) { usePagination := IsPaginationRequested(c) params := GetPaginationParams(c) + // Filter by status - default to active only + statusFilter := c.DefaultQuery("status", "active") + statusCondition := "WHERE status = 'active'" + if statusFilter == "all" { + statusCondition = "" + } else if statusFilter == "disabled" { + statusCondition = "WHERE status = 'disabled'" + } + var total int if usePagination { - err := h.DB.QueryRow("SELECT COUNT(*) FROM services").Scan(&total) + countQuery := "SELECT COUNT(*) FROM services " + statusCondition + err := h.DB.QueryRow(countQuery).Scan(&total) if err != nil { log.Printf("Error counting services: %v", err) ResponseWithError(c, http.StatusInternalServerError, "Failed to count services") @@ -38,7 +51,7 @@ func (h *ServiceHandler) GetServices(c *gin.Context) { } } - query := "SELECT id, name, type, config FROM services ORDER BY name" + query := "SELECT id, name, type, config, COALESCE(status, 'active') as status, COALESCE(source_type, '') as source_type FROM services " + statusCondition + " ORDER BY name" var rows *sql.Rows var err error @@ -58,8 +71,8 @@ func (h *ServiceHandler) GetServices(c *gin.Context) { services := []map[string]interface{}{} for rows.Next() { - var id, name, typ, configStr string - if err := rows.Scan(&id, &name, &typ, &configStr); err != nil { + var id, name, typ, configStr, status, sourceType string + if err := rows.Scan(&id, &name, &typ, &configStr, &status, &sourceType); err != nil { log.Printf("Error scanning service row: %v", err) continue } @@ -71,10 +84,12 @@ func (h *ServiceHandler) GetServices(c *gin.Context) { } services = append(services, map[string]interface{}{ - "id": id, - "name": name, - "type": typ, - "config": config, + "id": id, + "name": name, + "type": typ, + "config": config, + "status": status, + "source_type": sourceType, }) } @@ -136,7 +151,7 @@ func (h *ServiceHandler) CreateService(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // If something goes wrong, rollback var txErr error defer func() { @@ -145,21 +160,21 @@ func (h *ServiceHandler) CreateService(c *gin.Context) { log.Printf("Transaction rolled back due to error: %v", txErr) } }() - - log.Printf("Attempting to insert service with ID=%s, name=%s, type=%s", + + log.Printf("Attempting to insert service with ID=%s, name=%s, type=%s", id, service.Name, service.Type) - + result, txErr := tx.Exec( - "INSERT INTO services (id, name, type, config) VALUES (?, ?, ?, ?)", + "INSERT INTO services (id, name, type, config, status, source_type) VALUES (?, ?, ?, ?, 'active', 'manual')", id, service.Name, service.Type, string(configJSON), ) - + if txErr != nil { log.Printf("Error inserting service: %v", txErr) ResponseWithError(c, http.StatusInternalServerError, "Failed to save service") return } - + rowsAffected, err := result.RowsAffected() if err == nil { log.Printf("Insert affected %d rows", rowsAffected) @@ -192,8 +207,7 @@ func (h *ServiceHandler) GetService(c *gin.Context) { return } - var name, typ, configStr string - err := h.DB.QueryRow("SELECT name, type, config FROM services WHERE id = ?", id).Scan(&name, &typ, &configStr) + rec, err := h.findServiceByID(id) if err == sql.ErrNoRows { ResponseWithError(c, http.StatusNotFound, "Service not found") return @@ -204,16 +218,18 @@ func (h *ServiceHandler) GetService(c *gin.Context) { } var config map[string]interface{} - if err := json.Unmarshal([]byte(configStr), &config); err != nil { + if err := json.Unmarshal([]byte(rec.Config), &config); err != nil { log.Printf("Error parsing service config: %v", err) config = map[string]interface{}{} } c.JSON(http.StatusOK, gin.H{ - "id": id, - "name": name, - "type": typ, - "config": config, + "id": rec.ID, + "name": rec.Name, + "type": rec.Type, + "config": config, + "status": rec.Status, + "source_type": rec.SourceType, }) } @@ -242,9 +258,7 @@ func (h *ServiceHandler) UpdateService(c *gin.Context) { return } - // Check if service exists - var exists int - err := h.DB.QueryRow("SELECT 1 FROM services WHERE id = ?", id).Scan(&exists) + rec, err := h.findServiceByID(id) if err == sql.ErrNoRows { ResponseWithError(c, http.StatusNotFound, "Service not found") return @@ -272,7 +286,7 @@ func (h *ServiceHandler) UpdateService(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // If something goes wrong, rollback var txErr error defer func() { @@ -281,21 +295,21 @@ func (h *ServiceHandler) UpdateService(c *gin.Context) { log.Printf("Transaction rolled back due to error: %v", txErr) } }() - - log.Printf("Attempting to update service %s with name=%s, type=%s", + + log.Printf("Attempting to update service %s with name=%s, type=%s", id, service.Name, service.Type) - + result, txErr := tx.Exec( "UPDATE services SET name = ?, type = ?, config = ?, updated_at = ? WHERE id = ?", - service.Name, service.Type, string(configJSON), time.Now(), id, + service.Name, service.Type, string(configJSON), time.Now(), rec.ID, ) - + if txErr != nil { log.Printf("Error updating service: %v", txErr) ResponseWithError(c, http.StatusInternalServerError, "Failed to update service") return } - + rowsAffected, err := result.RowsAffected() if err == nil { log.Printf("Update affected %d rows", rowsAffected) @@ -303,7 +317,7 @@ func (h *ServiceHandler) UpdateService(c *gin.Context) { log.Printf("Warning: Update query succeeded but no rows were affected") } } - + // Commit the transaction if txErr = tx.Commit(); txErr != nil { log.Printf("Error committing transaction: %v", txErr) @@ -313,18 +327,18 @@ func (h *ServiceHandler) UpdateService(c *gin.Context) { // Double-check that the service was updated var updatedName string - err = h.DB.QueryRow("SELECT name FROM services WHERE id = ?", id).Scan(&updatedName) + err = h.DB.QueryRow("SELECT name FROM services WHERE id = ?", rec.ID).Scan(&updatedName) if err != nil { log.Printf("Warning: Could not verify service update: %v", err) } else if updatedName != service.Name { log.Printf("Warning: Name mismatch after update. Expected '%s', got '%s'", service.Name, updatedName) } else { - log.Printf("Successfully verified service update for %s", id) + log.Printf("Successfully verified service update for %s", rec.ID) } // Return the updated service c.JSON(http.StatusOK, gin.H{ - "id": id, + "id": rec.ID, "name": service.Name, "type": service.Type, "config": service.Config, @@ -339,9 +353,19 @@ func (h *ServiceHandler) DeleteService(c *gin.Context) { return } + rec, err := h.findServiceByID(id) + if err == sql.ErrNoRows { + ResponseWithError(c, http.StatusNotFound, "Service not found") + return + } else if err != nil { + log.Printf("Error fetching service for delete: %v", err) + ResponseWithError(c, http.StatusInternalServerError, "Database error") + return + } + // Check for dependencies first - resources using this service var count int - err := h.DB.QueryRow("SELECT COUNT(*) FROM resource_services WHERE service_id = ?", id).Scan(&count) + err = h.DB.QueryRow("SELECT COUNT(*) FROM resource_services WHERE service_id = ?", rec.ID).Scan(&count) if err != nil { log.Printf("Error checking service dependencies: %v", err) ResponseWithError(c, http.StatusInternalServerError, "Database error") @@ -360,7 +384,7 @@ func (h *ServiceHandler) DeleteService(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // If something goes wrong, rollback var txErr error defer func() { @@ -369,10 +393,10 @@ func (h *ServiceHandler) DeleteService(c *gin.Context) { log.Printf("Transaction rolled back due to error: %v", txErr) } }() - - log.Printf("Attempting to delete service %s", id) - result, txErr := tx.Exec("DELETE FROM services WHERE id = ?", id) + log.Printf("Attempting to delete service %s", rec.ID) + + result, txErr := tx.Exec("DELETE FROM services WHERE id = ?", rec.ID) if txErr != nil { log.Printf("Error deleting service: %v", txErr) ResponseWithError(c, http.StatusInternalServerError, "Failed to delete service") @@ -392,7 +416,7 @@ func (h *ServiceHandler) DeleteService(c *gin.Context) { } // Track deletion to prevent template from being re-created on restart - _, txErr = tx.Exec("INSERT OR REPLACE INTO deleted_templates (id, type) VALUES (?, 'service')", id) + _, txErr = tx.Exec("INSERT OR REPLACE INTO deleted_templates (id, type) VALUES (?, 'service')", rec.ID) if txErr != nil { log.Printf("Warning: Failed to track deleted template: %v", txErr) // Continue anyway - this is not critical @@ -407,7 +431,7 @@ func (h *ServiceHandler) DeleteService(c *gin.Context) { return } - log.Printf("Successfully deleted service %s", id) + log.Printf("Successfully deleted service %s", rec.ID) c.JSON(http.StatusOK, gin.H{"message": "Service deleted successfully"}) } @@ -440,15 +464,15 @@ func (h *ServiceHandler) AssignServiceToResource(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // Don't allow attaching services to disabled resources if status == "disabled" { ResponseWithError(c, http.StatusBadRequest, "Cannot assign service to a disabled resource") return } - // Verify service exists - err = h.DB.QueryRow("SELECT 1 FROM services WHERE id = ?", input.ServiceID).Scan(&exists) + // Verify service exists (supports normalized IDs / provider suffixes) + serviceRec, err := h.findServiceByID(input.ServiceID) if err == sql.ErrNoRows { ResponseWithError(c, http.StatusNotFound, "Service not found") return @@ -465,7 +489,7 @@ func (h *ServiceHandler) AssignServiceToResource(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // If something goes wrong, rollback var txErr error defer func() { @@ -474,7 +498,7 @@ func (h *ServiceHandler) AssignServiceToResource(c *gin.Context) { log.Printf("Transaction rolled back due to error: %v", txErr) } }() - + // First delete any existing relationship log.Printf("Removing existing service relationship: resource=%s", resourceID) _, txErr = tx.Exec( @@ -486,26 +510,26 @@ func (h *ServiceHandler) AssignServiceToResource(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // Then insert the new relationship log.Printf("Creating new service relationship: resource=%s, service=%s", - resourceID, input.ServiceID) + resourceID, serviceRec.ID) result, txErr := tx.Exec( "INSERT INTO resource_services (resource_id, service_id) VALUES (?, ?)", - resourceID, input.ServiceID, + resourceID, serviceRec.ID, ) - + if txErr != nil { log.Printf("Error assigning service: %v", txErr) ResponseWithError(c, http.StatusInternalServerError, "Failed to assign service") return } - + rowsAffected, err := result.RowsAffected() if err == nil { log.Printf("Insert affected %d rows", rowsAffected) } - + // Commit the transaction if txErr = tx.Commit(); txErr != nil { log.Printf("Error committing transaction: %v", txErr) @@ -514,10 +538,10 @@ func (h *ServiceHandler) AssignServiceToResource(c *gin.Context) { } log.Printf("Successfully assigned service %s to resource %s", - input.ServiceID, resourceID) + serviceRec.ID, resourceID) c.JSON(http.StatusOK, gin.H{ "resource_id": resourceID, - "service_id": input.ServiceID, + "service_id": serviceRec.ID, }) } @@ -538,7 +562,7 @@ func (h *ServiceHandler) RemoveServiceFromResource(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + // If something goes wrong, rollback var txErr error defer func() { @@ -547,12 +571,12 @@ func (h *ServiceHandler) RemoveServiceFromResource(c *gin.Context) { log.Printf("Transaction rolled back due to error: %v", txErr) } }() - + result, txErr := tx.Exec( "DELETE FROM resource_services WHERE resource_id = ?", resourceID, ) - + if txErr != nil { log.Printf("Error removing service: %v", txErr) ResponseWithError(c, http.StatusInternalServerError, "Failed to remove service") @@ -565,15 +589,15 @@ func (h *ServiceHandler) RemoveServiceFromResource(c *gin.Context) { ResponseWithError(c, http.StatusInternalServerError, "Database error") return } - + if rowsAffected == 0 { log.Printf("No service assignment found for resource %s", resourceID) ResponseWithError(c, http.StatusNotFound, "Resource service relationship not found") return } - + log.Printf("Delete affected %d rows", rowsAffected) - + // Commit the transaction if txErr = tx.Commit(); txErr != nil { log.Printf("Error committing transaction: %v", txErr) @@ -631,4 +655,50 @@ func (h *ServiceHandler) GetResourceService(c *gin.Context) { "config": config, }, }) -} \ No newline at end of file +} + +type serviceRecord struct { + ID string + Name string + Type string + Config string + Status string + SourceType string +} + +// findServiceByID resolves a service by exact ID, normalized ID, or provider-suffixed variants. +func (h *ServiceHandler) findServiceByID(id string) (serviceRecord, error) { + candidates := []string{id} + normalized := util.NormalizeID(id) + if normalized != id { + candidates = append(candidates, normalized) + } + if !strings.Contains(normalized, "@") { + candidates = append(candidates, normalized+"@%") + } + + var rec serviceRecord + for _, candidate := range candidates { + var err error + if strings.Contains(candidate, "%") { + err = h.DB.QueryRow( + "SELECT id, name, type, config, COALESCE(status, 'active'), COALESCE(source_type, '') FROM services WHERE id LIKE ? LIMIT 1", + candidate, + ).Scan(&rec.ID, &rec.Name, &rec.Type, &rec.Config, &rec.Status, &rec.SourceType) + } else { + err = h.DB.QueryRow( + "SELECT id, name, type, config, COALESCE(status, 'active'), COALESCE(source_type, '') FROM services WHERE id = ?", + candidate, + ).Scan(&rec.ID, &rec.Name, &rec.Type, &rec.Config, &rec.Status, &rec.SourceType) + } + + if err == nil { + return rec, nil + } + if err != sql.ErrNoRows { + return serviceRecord{}, err + } + } + + return serviceRecord{}, sql.ErrNoRows +} diff --git a/api/server.go b/api/server.go index b7d6c2f6..c56b43aa 100644 --- a/api/server.go +++ b/api/server.go @@ -149,7 +149,7 @@ func (s *Server) setupRoutes(uiPath string) { s.router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) - + // API routes api := s.router.Group("/api") { @@ -179,17 +179,18 @@ func (s *Server) setupRoutes(uiPath string) { resources.GET("", s.resourceHandler.GetResources) resources.GET("/:id", s.resourceHandler.GetResource) resources.DELETE("/:id", s.resourceHandler.DeleteResource) - + resources.POST("/bulk-delete-disabled", s.resourceHandler.DeleteDisabledResources) + // Middleware assignments resources.POST("/:id/middlewares", s.resourceHandler.AssignMiddleware) resources.POST("/:id/middlewares/bulk", s.resourceHandler.AssignMultipleMiddlewares) resources.DELETE("/:id/middlewares/:middlewareId", s.resourceHandler.RemoveMiddleware) - + // Service assignments resources.GET("/:id/service", s.serviceHandler.GetResourceService) resources.POST("/:id/service", s.serviceHandler.AssignServiceToResource) resources.DELETE("/:id/service", s.serviceHandler.RemoveServiceFromResource) - + // Router configuration routes resources.PUT("/:id/config/http", s.configHandler.UpdateHTTPConfig) resources.PUT("/:id/config/tls", s.configHandler.UpdateTLSConfig) @@ -294,11 +295,11 @@ func (s *Server) setupRoutes(uiPath string) { // Default UI path uiPathToUse = "/app/ui/dist" } - + // Check if UI path exists and is a directory if stat, err := os.Stat(uiPathToUse); err == nil && stat.IsDir() { s.router.Use(static.Serve("/", static.LocalFile(uiPathToUse, false))) - + // Handle all other routes by serving the index.html file s.router.NoRoute(func(c *gin.Context) { // API routes should 404 when not found @@ -306,7 +307,7 @@ func (s *Server) setupRoutes(uiPath string) { c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"}) return } - + // Non-API routes serve the SPA c.File(uiPathToUse + "/index.html") }) @@ -363,7 +364,7 @@ func (s *Server) Start() error { func (s *Server) Stop() { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - + if err := s.srv.Shutdown(ctx); err != nil { log.Printf("Failed to gracefully shutdown server: %v", err) if err := s.srv.Close(); err != nil { @@ -379,10 +380,10 @@ func minimalLogger() gin.HandlerFunc { return func(c *gin.Context) { // Start timer start := time.Now() - + // Process request c.Next() - + // Log only when path is not being probed by health checkers if c.Request.URL.Path != "/health" && c.Request.URL.Path != "/ping" { // Log only requests with errors or non-standard responses @@ -397,4 +398,4 @@ func minimalLogger() gin.HandlerFunc { } } } -} \ No newline at end of file +} diff --git a/database/db.go b/database/db.go index da1a195c..adaa8605 100644 --- a/database/db.go +++ b/database/db.go @@ -578,6 +578,45 @@ func runPostMigrationUpdates(db *sql.DB) error { // Create index on host for faster lookups when matching by host _, _ = db.Exec("CREATE INDEX IF NOT EXISTS idx_resources_host ON resources(host)") + // Check for status column in services table (for tracking sync state) + var hasServicesStatusColumn bool + err = db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('services') + WHERE name = 'status' + `).Scan(&hasServicesStatusColumn) + if err != nil { + return fmt.Errorf("failed to check if services.status column exists: %w", err) + } + if !hasServicesStatusColumn { + log.Println("Adding status column to services table") + if _, err := db.Exec("ALTER TABLE services ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); err != nil { + return fmt.Errorf("failed to add status column to services: %w", err) + } + log.Println("Successfully added status column to services table") + } + + // Check for source_type column in services table (for tracking sync origin) + var hasServicesSourceTypeColumn bool + err = db.QueryRow(` + SELECT COUNT(*) > 0 + FROM pragma_table_info('services') + WHERE name = 'source_type' + `).Scan(&hasServicesSourceTypeColumn) + if err != nil { + return fmt.Errorf("failed to check if services.source_type column exists: %w", err) + } + if !hasServicesSourceTypeColumn { + log.Println("Adding source_type column to services table") + if _, err := db.Exec("ALTER TABLE services ADD COLUMN source_type TEXT DEFAULT ''"); err != nil { + return fmt.Errorf("failed to add source_type column to services: %w", err) + } + log.Println("Successfully added source_type column to services table") + } + + // Create index on services status for faster filtering + _, _ = db.Exec("CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)") + return nil } diff --git a/database/migrations.sql b/database/migrations.sql index ae232a60..a3828e34 100644 --- a/database/migrations.sql +++ b/database/migrations.sql @@ -73,6 +73,8 @@ CREATE TABLE IF NOT EXISTS services ( name TEXT NOT NULL, type TEXT NOT NULL, config TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + source_type TEXT DEFAULT '', -- 'pangolin', 'traefik', 'manual', etc. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); diff --git a/main.go b/main.go index 67c1a55f..3e2f318d 100644 --- a/main.go +++ b/main.go @@ -218,8 +218,9 @@ func loadConfiguration(debug bool) Configuration { } return Configuration{ - PangolinAPIURL: getEnv("PANGOLIN_API_URL", "http://pangolin:3001/api/v1"), - TraefikAPIURL: getEnv("TRAEFIK_API_URL", "http://host.docker.internal:8080"), + PangolinAPIURL: getEnv("PANGOLIN_API_URL", "http://pangolin:3001/api/v1"), + // Default to in-network Traefik service; host.docker.internal often fails inside containers + TraefikAPIURL: getEnv("TRAEFIK_API_URL", "http://traefik:8080"), TraefikConfDir: getEnv("TRAEFIK_CONF_DIR", "/conf"), DBPath: getEnv("DB_PATH", "/data/middleware.db"), Port: getEnv("PORT", "3456"), diff --git a/services/config_manager.go b/services/config_manager.go index 68a9c041..039ba030 100644 --- a/services/config_manager.go +++ b/services/config_manager.go @@ -1,328 +1,329 @@ package services import ( - "context" - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/hhftechnology/middleware-manager/models" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/hhftechnology/middleware-manager/models" ) // ConfigManager manages system configuration type ConfigManager struct { - configPath string - config models.SystemConfig - mu sync.RWMutex + configPath string + config models.SystemConfig + mu sync.RWMutex } // NewConfigManager creates a new config manager func NewConfigManager(configPath string) (*ConfigManager, error) { - cm := &ConfigManager{ - configPath: configPath, - } - - if err := cm.loadConfig(); err != nil { - return nil, err - } - - return cm, nil + cm := &ConfigManager{ + configPath: configPath, + } + + if err := cm.loadConfig(); err != nil { + return nil, err + } + + return cm, nil } // loadConfig loads configuration from file func (cm *ConfigManager) loadConfig() error { - cm.mu.Lock() - defer cm.mu.Unlock() - - // Check if config file exists - if _, err := os.Stat(cm.configPath); os.IsNotExist(err) { - // Create default config - cm.config = models.SystemConfig{ - ActiveDataSource: "pangolin", - DataSources: map[string]models.DataSourceConfig{ - "pangolin": { - Type: models.PangolinAPI, - URL: "http://pangolin:3001/api/v1", - }, - "traefik": { - Type: models.TraefikAPI, - URL: "http://host.docker.internal:8080", - }, - }, - } - - // Save default config - return cm.saveConfig() - } - - // Read config file - data, err := os.ReadFile(cm.configPath) - if err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - - // Parse config - if err := json.Unmarshal(data, &cm.config); err != nil { - return fmt.Errorf("failed to parse config: %w", err) - } - - return nil + cm.mu.Lock() + defer cm.mu.Unlock() + + // Check if config file exists + if _, err := os.Stat(cm.configPath); os.IsNotExist(err) { + // Create default config + cm.config = models.SystemConfig{ + ActiveDataSource: "pangolin", + DataSources: map[string]models.DataSourceConfig{ + "pangolin": { + Type: models.PangolinAPI, + URL: "http://pangolin:3001/api/v1", + }, + "traefik": { + Type: models.TraefikAPI, + URL: "http://host.docker.internal:8080", + }, + }, + } + + // Save default config + return cm.saveConfig() + } + + // Read config file + data, err := os.ReadFile(cm.configPath) + if err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + + // Parse config + if err := json.Unmarshal(data, &cm.config); err != nil { + return fmt.Errorf("failed to parse config: %w", err) + } + + return nil } // EnsureDefaultDataSources ensures default data sources are configured func (cm *ConfigManager) EnsureDefaultDataSources(pangolinURL, traefikURL string) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - // Ensure data sources map exists - if cm.config.DataSources == nil { - cm.config.DataSources = make(map[string]models.DataSourceConfig) - } - - // Add default Pangolin data source if not present - if _, exists := cm.config.DataSources["pangolin"]; !exists { - cm.config.DataSources["pangolin"] = models.DataSourceConfig{ - Type: models.PangolinAPI, - URL: pangolinURL, - } - } else { - // Ensure Type is set for existing Pangolin config (fix for old configs) - pConfig := cm.config.DataSources["pangolin"] - if pConfig.Type == "" { - pConfig.Type = models.PangolinAPI - if pConfig.URL == "" { - pConfig.URL = pangolinURL - } - cm.config.DataSources["pangolin"] = pConfig - log.Printf("Fixed missing Type for pangolin data source") - } - } - - // Add default Traefik data source if not present - if _, exists := cm.config.DataSources["traefik"]; !exists { - cm.config.DataSources["traefik"] = models.DataSourceConfig{ - Type: models.TraefikAPI, - URL: traefikURL, - } - } else { - // Ensure Type is set for existing Traefik config (fix for old configs) - tConfig := cm.config.DataSources["traefik"] - if tConfig.Type == "" { - tConfig.Type = models.TraefikAPI - log.Printf("Fixed missing Type for traefik data source") - } - // Update Traefik URL if provided (could be auto-discovered) - if traefikURL != "" && tConfig.URL != traefikURL { - log.Printf("Updating Traefik URL from %s to %s", tConfig.URL, traefikURL) - tConfig.URL = traefikURL - } - cm.config.DataSources["traefik"] = tConfig - } - - // Ensure there's an active data source - if cm.config.ActiveDataSource == "" { - cm.config.ActiveDataSource = "pangolin" - } - - // Try to determine if Traefik is available - if cm.config.ActiveDataSource == "pangolin" { - client := &http.Client{Timeout: 2 * time.Second} - traefikConfig := cm.config.DataSources["traefik"] - - // Try the Traefik URL - resp, err := client.Get(traefikConfig.URL + "/api/version") - if err == nil && resp.StatusCode == http.StatusOK { - resp.Body.Close() - // Traefik is available, but not active - log a message - log.Printf("Note: Traefik API appears to be available at %s but is not the active source", traefikConfig.URL) - } - if resp != nil { - resp.Body.Close() - } - } - - // Save the updated configuration - return cm.saveConfig() + cm.mu.Lock() + defer cm.mu.Unlock() + + // Ensure data sources map exists + if cm.config.DataSources == nil { + cm.config.DataSources = make(map[string]models.DataSourceConfig) + } + + // Add default Pangolin data source if not present + if _, exists := cm.config.DataSources["pangolin"]; !exists { + cm.config.DataSources["pangolin"] = models.DataSourceConfig{ + Type: models.PangolinAPI, + URL: pangolinURL, + } + } else { + // Ensure Type is set for existing Pangolin config (fix for old configs) + pConfig := cm.config.DataSources["pangolin"] + if pConfig.Type == "" { + pConfig.Type = models.PangolinAPI + if pConfig.URL == "" { + pConfig.URL = pangolinURL + } + cm.config.DataSources["pangolin"] = pConfig + log.Printf("Fixed missing Type for pangolin data source") + } + } + + // Add default Traefik data source if not present + if _, exists := cm.config.DataSources["traefik"]; !exists { + cm.config.DataSources["traefik"] = models.DataSourceConfig{ + Type: models.TraefikAPI, + URL: traefikURL, + } + } else { + // Ensure Type is set for existing Traefik config (fix for old configs) + tConfig := cm.config.DataSources["traefik"] + if tConfig.Type == "" { + tConfig.Type = models.TraefikAPI + log.Printf("Fixed missing Type for traefik data source") + } + // Update Traefik URL if provided (could be auto-discovered) + if traefikURL != "" && tConfig.URL != traefikURL { + log.Printf("Updating Traefik URL from %s to %s", tConfig.URL, traefikURL) + tConfig.URL = traefikURL + } + cm.config.DataSources["traefik"] = tConfig + } + + // Ensure there's an active data source + if cm.config.ActiveDataSource == "" { + cm.config.ActiveDataSource = "pangolin" + } + + // Try to determine if Traefik is available + if cm.config.ActiveDataSource == "pangolin" { + client := &http.Client{Timeout: 2 * time.Second} + traefikConfig := cm.config.DataSources["traefik"] + + // Try the Traefik URL + resp, err := client.Get(traefikConfig.URL + "/api/version") + if err == nil && resp.StatusCode == http.StatusOK { + resp.Body.Close() + // Traefik is available, but not active - log a message + log.Printf("Note: Traefik API appears to be available at %s but is not the active source", traefikConfig.URL) + } + if resp != nil { + resp.Body.Close() + } + } + + // Save the updated configuration + return cm.saveConfig() } // saveConfig saves configuration to file func (cm *ConfigManager) saveConfig() error { - // Create directory if it doesn't exist - dir := filepath.Dir(cm.configPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - - // Marshal config to JSON - data, err := json.MarshalIndent(cm.config, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal config: %w", err) - } - - // Write config file - if err := os.WriteFile(cm.configPath, data, 0644); err != nil { - return fmt.Errorf("failed to write config file: %w", err) - } - - return nil + // Create directory if it doesn't exist + dir := filepath.Dir(cm.configPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + // Marshal config to JSON + data, err := json.MarshalIndent(cm.config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // Write config file + if err := os.WriteFile(cm.configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + + return nil } // GetActiveDataSourceConfig returns the active data source configuration func (cm *ConfigManager) GetActiveDataSourceConfig() (models.DataSourceConfig, error) { - cm.mu.RLock() - defer cm.mu.RUnlock() - - dsName := cm.config.ActiveDataSource - ds, ok := cm.config.DataSources[dsName] - if !ok { - return models.DataSourceConfig{}, fmt.Errorf("active data source not found: %s", dsName) - } - - // Fallback: infer Type from name if empty (for old configs) - if ds.Type == "" { - switch dsName { - case "pangolin": - ds.Type = models.PangolinAPI - case "traefik": - ds.Type = models.TraefikAPI - default: - return models.DataSourceConfig{}, fmt.Errorf("unknown data source type for: %s", dsName) - } - } - - return ds, nil + cm.mu.RLock() + defer cm.mu.RUnlock() + + dsName := cm.config.ActiveDataSource + ds, ok := cm.config.DataSources[dsName] + if !ok { + return models.DataSourceConfig{}, fmt.Errorf("active data source not found: %s", dsName) + } + + // Fallback: infer Type from name if empty (for old configs) + if ds.Type == "" { + switch dsName { + case "pangolin": + ds.Type = models.PangolinAPI + case "traefik": + ds.Type = models.TraefikAPI + default: + return models.DataSourceConfig{}, fmt.Errorf("unknown data source type for: %s", dsName) + } + } + + return ds, nil } // GetActiveSourceName returns the name of the active data source func (cm *ConfigManager) GetActiveSourceName() string { - cm.mu.RLock() - defer cm.mu.RUnlock() - - return cm.config.ActiveDataSource + cm.mu.RLock() + defer cm.mu.RUnlock() + + return cm.config.ActiveDataSource } // SetActiveDataSource sets the active data source func (cm *ConfigManager) SetActiveDataSource(name string) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - if _, ok := cm.config.DataSources[name]; !ok { - return fmt.Errorf("data source not found: %s", name) - } - - // Skip if already active - if cm.config.ActiveDataSource == name { - return nil - } - - // Store the previous active source for logging - oldSource := cm.config.ActiveDataSource - - // Update active source - cm.config.ActiveDataSource = name - - // Log the change - log.Printf("Changed active data source from %s to %s", oldSource, name) - - return cm.saveConfig() + cm.mu.Lock() + defer cm.mu.Unlock() + + if _, ok := cm.config.DataSources[name]; !ok { + return fmt.Errorf("data source not found: %s", name) + } + + // Skip if already active + if cm.config.ActiveDataSource == name { + return nil + } + + // Store the previous active source for logging + oldSource := cm.config.ActiveDataSource + + // Update active source + cm.config.ActiveDataSource = name + + // Log the change + log.Printf("Changed active data source from %s to %s", oldSource, name) + + return cm.saveConfig() } // GetDataSources returns all configured data sources func (cm *ConfigManager) GetDataSources() map[string]models.DataSourceConfig { - cm.mu.RLock() - defer cm.mu.RUnlock() - - // Return a copy to prevent map mutation - sources := make(map[string]models.DataSourceConfig) - for k, v := range cm.config.DataSources { - sources[k] = v - } - - return sources + cm.mu.RLock() + defer cm.mu.RUnlock() + + // Return a copy to prevent map mutation + sources := make(map[string]models.DataSourceConfig) + for k, v := range cm.config.DataSources { + sources[k] = v + } + + return sources } // UpdateDataSource updates a data source configuration func (cm *ConfigManager) UpdateDataSource(name string, config models.DataSourceConfig) error { - cm.mu.Lock() - defer cm.mu.Unlock() - - // Create a copy to avoid reference issues - newConfig := config - - // Ensure URL doesn't end with a slash - if newConfig.URL != "" && strings.HasSuffix(newConfig.URL, "/") { - newConfig.URL = strings.TrimSuffix(newConfig.URL, "/") - } - - // Test the connection before saving - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - if err := cm.testDataSourceConnection(ctx, newConfig); err != nil { - log.Printf("Warning: Data source connection test failed: %v", err) - // Continue anyway but log the warning - } - - // Update the config - cm.config.DataSources[name] = newConfig - - // If this is the active data source, log a special message - if cm.config.ActiveDataSource == name { - log.Printf("Updated active data source '%s'", name) - } - - return cm.saveConfig() + cm.mu.Lock() + defer cm.mu.Unlock() + + // Create a copy to avoid reference issues + newConfig := config + + // Ensure URL doesn't end with a slash + if newConfig.URL != "" && strings.HasSuffix(newConfig.URL, "/") { + newConfig.URL = strings.TrimSuffix(newConfig.URL, "/") + } + + // Test the connection before saving + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := cm.testDataSourceConnection(ctx, newConfig); err != nil { + log.Printf("Warning: Data source connection test failed: %v", err) + // Continue anyway but log the warning + } + + // Update the config + cm.config.DataSources[name] = newConfig + + // If this is the active data source, log a special message + if cm.config.ActiveDataSource == name { + log.Printf("Updated active data source '%s'", name) + } + + return cm.saveConfig() } // testDataSourceConnection tests the connection to a data source func (cm *ConfigManager) testDataSourceConnection(ctx context.Context, config models.DataSourceConfig) error { - client := &http.Client{ - Timeout: 5 * time.Second, - } - - var url string - switch config.Type { - case models.PangolinAPI: - url = config.URL + "/status" - case models.TraefikAPI: - url = config.URL + "/api/version" - default: - return fmt.Errorf("unsupported data source type: %s", config.Type) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - // Add basic auth if configured - if config.BasicAuth.Username != "" { - req.SetBasicAuth(config.BasicAuth.Username, config.BasicAuth.Password) - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("connection test failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode >= 400 { - return fmt.Errorf("connection test failed with status code: %d", resp.StatusCode) - } - - return nil + client := &http.Client{ + Timeout: 5 * time.Second, + } + + var url string + switch config.Type { + case models.PangolinAPI: + // Use the same health-check endpoint as API handler; Pangolin does not expose /status + url = config.URL + "/traefik-config" + case models.TraefikAPI: + url = config.URL + "/api/version" + default: + return fmt.Errorf("unsupported data source type: %s", config.Type) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + // Add basic auth if configured + if config.BasicAuth.Username != "" { + req.SetBasicAuth(config.BasicAuth.Username, config.BasicAuth.Password) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("connection test failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("connection test failed with status code: %d", resp.StatusCode) + } + + return nil } // TestDataSourceConnection is a public method to test a connection func (cm *ConfigManager) TestDataSourceConnection(config models.DataSourceConfig) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - return cm.testDataSourceConnection(ctx, config) -} \ No newline at end of file + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + return cm.testDataSourceConnection(ctx, config) +} diff --git a/services/pangolin_fetcher.go b/services/pangolin_fetcher.go index 5ade75a5..050f84df 100644 --- a/services/pangolin_fetcher.go +++ b/services/pangolin_fetcher.go @@ -158,11 +158,6 @@ func (f *PangolinFetcher) convertConfigToResources(config *models.PangolinTraefi } for id, router := range config.HTTP.Routers { - // Skip non-SSL routers (usually HTTP redirects) - if router.TLS.CertResolver == "" { - continue - } - // Extract host from rule host := extractHostFromRule(router.Rule) if host == "" { @@ -174,6 +169,11 @@ func (f *PangolinFetcher) convertConfigToResources(config *models.PangolinTraefi continue } + // Skip redirect routers; we track primary websecure routers only + if strings.HasSuffix(id, "-redirect") { + continue + } + // Use Pangolin's priority if provided, otherwise default to 100 priority := router.Priority if priority == 0 { diff --git a/services/resource_watcher.go b/services/resource_watcher.go index efe53c74..73655c9e 100644 --- a/services/resource_watcher.go +++ b/services/resource_watcher.go @@ -119,7 +119,6 @@ func (rw *ResourceWatcher) Stop() { // checkResources fetches resources from the configured data source and updates the database func (rw *ResourceWatcher) checkResources() error { - log.Println("Checking for resources using configured data source...") // Create a context with timeout for the operation ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) @@ -219,8 +218,7 @@ func (rw *ResourceWatcher) updateOrCreateResource(resource models.Resource) (str `, pangolinRouterID).Scan(&internalID, &status) if err == nil { - // Found by pangolin_router_id - update it - log.Printf("Found resource by pangolin_router_id %s (internal: %s)", pangolinRouterID, internalID) + // Found by pangolin_router_id - update it (only if changed) if err := rw.updateExistingResourceByInternalID(internalID, pangolinRouterID, resource); err != nil { return "", err } @@ -234,9 +232,7 @@ func (rw *ResourceWatcher) updateOrCreateResource(resource models.Resource) (str `, resource.Host).Scan(&internalID, &status) if err == nil { - // Found by host - Pangolin changed the router ID, just update pangolin_router_id - log.Printf("Found resource by host %s (internal: %s), updating pangolin_router_id from old to %s", - resource.Host, internalID, pangolinRouterID) + // Found by host - Pangolin changed the router ID, update pangolin_router_id (only if changed) if err := rw.updateExistingResourceByInternalID(internalID, pangolinRouterID, resource); err != nil { return "", err } @@ -250,8 +246,7 @@ func (rw *ResourceWatcher) updateOrCreateResource(resource models.Resource) (str `, pangolinRouterID, resource.Host).Scan(&internalID, &status) if err == nil { - // Found legacy resource - update it - log.Printf("Found legacy resource %s, updating", internalID) + // Found legacy resource - update it (only if changed) if err := rw.updateExistingResourceByInternalID(internalID, pangolinRouterID, resource); err != nil { return "", err } @@ -263,18 +258,56 @@ func (rw *ResourceWatcher) updateOrCreateResource(resource models.Resource) (str } // updateExistingResourceByInternalID updates an existing resource using its internal UUID +// Only performs update if the data has actually changed func (rw *ResourceWatcher) updateExistingResourceByInternalID(internalID, pangolinRouterID string, resource models.Resource) error { + // First, check if any data has actually changed + var existingPangolinRouterID, existingHost, existingServiceID, existingSourceType, existingEntrypoints string + var existingRouterPriority int + var routerPriorityManual int + + err := rw.db.QueryRow(` + SELECT COALESCE(pangolin_router_id, ''), host, service_id, COALESCE(source_type, ''), + COALESCE(entrypoints, ''), COALESCE(router_priority, 0), COALESCE(router_priority_manual, 0) + FROM resources WHERE id = ? + `, internalID).Scan(&existingPangolinRouterID, &existingHost, &existingServiceID, + &existingSourceType, &existingEntrypoints, &existingRouterPriority, &routerPriorityManual) + + if err != nil { + // If we can't read existing data, proceed with update + log.Printf("Warning: Could not read existing resource %s: %v - will update", internalID, err) + } else { + // Check if essential fields have changed + essentialFieldsChanged := existingPangolinRouterID != pangolinRouterID || + existingHost != resource.Host || + existingServiceID != resource.ServiceID || + existingSourceType != resource.SourceType || + existingEntrypoints != resource.Entrypoints + + // Check if router priority needs update (only if not manually overridden) + priorityNeedsUpdate := resource.RouterPriority > 0 && + routerPriorityManual == 0 && + existingRouterPriority != resource.RouterPriority + + // If nothing changed, skip the update entirely + if !essentialFieldsChanged && !priorityNeedsUpdate { + return nil + } + } + return rw.db.WithTransaction(func(tx *sql.Tx) error { - log.Printf("Updating resource (internal: %s, pangolin: %s, host: %s)", - internalID, pangolinRouterID, resource.Host) + log.Printf("Updating resource (internal: %s, pangolin: %s, host: %s, entrypoints: %s)", + internalID, pangolinRouterID, resource.Host, resource.Entrypoints) // Update essential fields and pangolin_router_id, preserve custom configuration _, err := tx.Exec(` UPDATE resources SET pangolin_router_id = ?, host = ?, service_id = ?, - status = 'active', source_type = ?, updated_at = ? + status = 'active', source_type = ?, entrypoints = ?, + tls_domains = ?, tcp_enabled = ?, updated_at = ? WHERE id = ? - `, pangolinRouterID, resource.Host, resource.ServiceID, resource.SourceType, time.Now(), internalID) + `, pangolinRouterID, resource.Host, resource.ServiceID, resource.SourceType, + resource.Entrypoints, resource.TLSDomains, resource.TCPEnabled, + time.Now(), internalID) if err != nil { return fmt.Errorf("failed to update resource %s: %w", internalID, err) diff --git a/services/service_fetcher.go b/services/service_fetcher.go index 372b184f..1db196ab 100644 --- a/services/service_fetcher.go +++ b/services/service_fetcher.go @@ -1,760 +1,759 @@ package services import ( - "context" - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "strings" - "time" - - "github.com/hhftechnology/middleware-manager/models" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/hhftechnology/middleware-manager/models" ) // ServiceFetcher defines the interface for fetching services type ServiceFetcher interface { - FetchServices(ctx context.Context) (*models.ServiceCollection, error) + FetchServices(ctx context.Context) (*models.ServiceCollection, error) } // ServiceFetcherFactory creates the appropriate service fetcher based on type func NewServiceFetcher(config models.DataSourceConfig) (ServiceFetcher, error) { - switch config.Type { - case models.PangolinAPI: - return NewPangolinServiceFetcher(config), nil - case models.TraefikAPI: - return NewTraefikServiceFetcher(config), nil - default: - return nil, fmt.Errorf("unknown data source type: %s", config.Type) - } + switch config.Type { + case models.PangolinAPI: + return NewPangolinServiceFetcher(config), nil + case models.TraefikAPI: + return NewTraefikServiceFetcher(config), nil + default: + return nil, fmt.Errorf("unknown data source type: %s", config.Type) + } } // PangolinServiceFetcher fetches services from Pangolin API type PangolinServiceFetcher struct { - config models.DataSourceConfig - httpClient *http.Client + config models.DataSourceConfig + httpClient *http.Client } // NewPangolinServiceFetcher creates a new Pangolin API fetcher for services func NewPangolinServiceFetcher(config models.DataSourceConfig) *PangolinServiceFetcher { - return &PangolinServiceFetcher{ - config: config, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } + return &PangolinServiceFetcher{ + config: config, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } } // FetchServices fetches services from Pangolin API func (f *PangolinServiceFetcher) FetchServices(ctx context.Context) (*models.ServiceCollection, error) { - // Create HTTP request - req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.config.URL+"/traefik-config", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add basic auth if configured - if f.config.BasicAuth.Username != "" { - req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) - } - - // Execute request - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // Process response - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Parse the Pangolin config (which includes services) - var config models.PangolinTraefikConfig - if err := json.Unmarshal(body, &config); err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) - } - - // Convert Pangolin services to our internal model - services := &models.ServiceCollection{ - Services: make([]models.Service, 0), - } - - // Extract HTTP services - for id, service := range config.HTTP.Services { - // Skip system services - if isPangolinSystemService(id) { - continue - } - - // Extract service configuration - serviceConfig := make(map[string]interface{}) - - // Determine service type based on structure - serviceType := determineServiceType(service) - - // Extract the appropriate configuration based on type - switch serviceType { - case string(models.LoadBalancerType): - if lb, ok := service.LoadBalancer.(map[string]interface{}); ok { - serviceConfig = lb - } - case string(models.WeightedType): - if w, ok := service.Weighted.(map[string]interface{}); ok { - serviceConfig = w - } - case string(models.MirroringType): - if m, ok := service.Mirroring.(map[string]interface{}); ok { - serviceConfig = m - } - case string(models.FailoverType): - if f, ok := service.Failover.(map[string]interface{}); ok { - serviceConfig = f - } - } - - // Create new service - configJSON, _ := json.Marshal(serviceConfig) - - newService := models.Service{ - ID: id, - Name: id, // Use ID as name by default - Type: serviceType, - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - services.Services = append(services.Services, newService) - } - - // TODO: Extract TCP and UDP services if they exist in the Pangolin API response - - log.Printf("Fetched %d services from Pangolin API", len(services.Services)) - return services, nil + // Create HTTP request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.config.URL+"/traefik-config", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add basic auth if configured + if f.config.BasicAuth.Username != "" { + req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) + } + + // Execute request + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Process response + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse the Pangolin config (which includes services) + var config models.PangolinTraefikConfig + if err := json.Unmarshal(body, &config); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + // Convert Pangolin services to our internal model + services := &models.ServiceCollection{ + Services: make([]models.Service, 0), + } + + // Extract HTTP services + for id, service := range config.HTTP.Services { + // Skip system services + if isPangolinSystemService(id) { + continue + } + + // Extract service configuration + serviceConfig := make(map[string]interface{}) + + // Determine service type based on structure + serviceType := determineServiceType(service) + + // Extract the appropriate configuration based on type + switch serviceType { + case string(models.LoadBalancerType): + if lb, ok := service.LoadBalancer.(map[string]interface{}); ok { + serviceConfig = lb + } + case string(models.WeightedType): + if w, ok := service.Weighted.(map[string]interface{}); ok { + serviceConfig = w + } + case string(models.MirroringType): + if m, ok := service.Mirroring.(map[string]interface{}); ok { + serviceConfig = m + } + case string(models.FailoverType): + if f, ok := service.Failover.(map[string]interface{}); ok { + serviceConfig = f + } + } + + // Create new service + configJSON, _ := json.Marshal(serviceConfig) + + newService := models.Service{ + ID: id, + Name: id, // Use ID as name by default + Type: serviceType, + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + services.Services = append(services.Services, newService) + } + + // TODO: Extract TCP and UDP services if they exist in the Pangolin API response + + log.Printf("Fetched %d services from Pangolin API", len(services.Services)) + return services, nil } // determineServiceType determines the service type from the service structure func determineServiceType(service models.PangolinService) string { - // Check for various service types - if service.LoadBalancer != nil { - return string(models.LoadBalancerType) - } - if service.Weighted != nil { - return string(models.WeightedType) - } - if service.Mirroring != nil { - return string(models.MirroringType) - } - if service.Failover != nil { - return string(models.FailoverType) - } - - // Default to LoadBalancer if can't determine - return string(models.LoadBalancerType) + // Check for various service types + if service.LoadBalancer != nil { + return string(models.LoadBalancerType) + } + if service.Weighted != nil { + return string(models.WeightedType) + } + if service.Mirroring != nil { + return string(models.MirroringType) + } + if service.Failover != nil { + return string(models.FailoverType) + } + + // Default to LoadBalancer if can't determine + return string(models.LoadBalancerType) } // isPangolinSystemService checks if a service is a Pangolin system service (to be skipped) func isPangolinSystemService(serviceID string) bool { - systemPrefixes := []string{ - "api-service", - "next-service", - "noop", - } - - for _, prefix := range systemPrefixes { - if strings.Contains(serviceID, prefix) { - return true - } - } - - return false + systemPrefixes := []string{ + "api-service", + "next-service", + "noop", + } + + for _, prefix := range systemPrefixes { + if strings.Contains(serviceID, prefix) { + return true + } + } + + return false } // TraefikServiceFetcher fetches services from Traefik API type TraefikServiceFetcher struct { - config models.DataSourceConfig - httpClient *http.Client + config models.DataSourceConfig + httpClient *http.Client } // NewTraefikServiceFetcher creates a new Traefik API fetcher for services func NewTraefikServiceFetcher(config models.DataSourceConfig) *TraefikServiceFetcher { - return &TraefikServiceFetcher{ - config: config, - httpClient: &http.Client{ - Timeout: 10 * time.Second, - }, - } + return &TraefikServiceFetcher{ + config: config, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } } // FetchServices fetches services from Traefik API with fallback options func (f *TraefikServiceFetcher) FetchServices(ctx context.Context) (*models.ServiceCollection, error) { - log.Println("Fetching services from Traefik API...") - - // Try the configured URL first - services, err := f.fetchServicesFromURL(ctx, f.config.URL) - if err == nil { - log.Printf("Successfully fetched services from %s", f.config.URL) - return services, nil - } - - // Log the initial error - log.Printf("Failed to connect to primary Traefik API URL %s: %v", f.config.URL, err) - - // Try common fallback URLs - fallbackURLs := []string{ - "http://host.docker.internal:8080", - "http://localhost:8080", - "http://127.0.0.1:8080", - "http://traefik:8080", - } - - // Don't try the same URL twice - if f.config.URL != "" { - for i := len(fallbackURLs) - 1; i >= 0; i-- { - if fallbackURLs[i] == f.config.URL { - fallbackURLs = append(fallbackURLs[:i], fallbackURLs[i+1:]...) - } - } - } - - // Try each fallback URL - var lastErr error - for _, url := range fallbackURLs { - log.Printf("Trying fallback Traefik API URL for services: %s", url) - services, err := f.fetchServicesFromURL(ctx, url) - if err == nil { - // Success with fallback - remember this URL for next time - f.suggestURLUpdate(url) - return services, nil - } - lastErr = err - log.Printf("Fallback URL %s failed: %v", url, err) - } - - // All fallbacks failed - return nil, fmt.Errorf("all Traefik API connection attempts failed, last error: %w", lastErr) + log.Println("Fetching services from Traefik API...") + + // Try the configured URL first + services, err := f.fetchServicesFromURL(ctx, f.config.URL) + if err == nil { + log.Printf("Successfully fetched services from %s", f.config.URL) + return services, nil + } + + // Log the initial error + log.Printf("Failed to connect to primary Traefik API URL %s: %v", f.config.URL, err) + + // Try common fallback URLs + fallbackURLs := []string{ + "http://traefik:8080", + "http://localhost:8080", + "http://127.0.0.1:8080", + "http://host.docker.internal:8080", + } + + // Don't try the same URL twice + if f.config.URL != "" { + for i := len(fallbackURLs) - 1; i >= 0; i-- { + if fallbackURLs[i] == f.config.URL { + fallbackURLs = append(fallbackURLs[:i], fallbackURLs[i+1:]...) + } + } + } + + // Try each fallback URL + var lastErr error + for _, url := range fallbackURLs { + log.Printf("Trying fallback Traefik API URL for services: %s", url) + services, err := f.fetchServicesFromURL(ctx, url) + if err == nil { + // Success with fallback - remember this URL for next time + f.suggestURLUpdate(url) + return services, nil + } + lastErr = err + log.Printf("Fallback URL %s failed: %v", url, err) + } + + // All fallbacks failed + return nil, fmt.Errorf("all Traefik API connection attempts failed, last error: %w", lastErr) } // fetchServicesFromURL fetches services from a specific URL func (f *TraefikServiceFetcher) fetchServicesFromURL(ctx context.Context, baseURL string) (*models.ServiceCollection, error) { - // Fetch HTTP services - httpServices, err := f.fetchHTTPServices(ctx, baseURL) - if err != nil { - return nil, fmt.Errorf("failed to fetch HTTP services: %w", err) - } - - // Try to fetch TCP services if available - tcpServices, err := f.fetchTCPServices(ctx, baseURL) - if err != nil { - // Log but don't fail - TCP services are optional - log.Printf("Warning: Failed to fetch TCP services: %v", err) - } - - // Try to fetch UDP services if available (may not be supported in all Traefik versions) - udpServices, err := f.fetchUDPServices(ctx, baseURL) - if err != nil { - // Log but don't fail - UDP services are optional - log.Printf("Warning: Failed to fetch UDP services: %v", err) - } - - // Combine all services - services := &models.ServiceCollection{ - Services: make([]models.Service, 0, len(httpServices)+len(tcpServices)+len(udpServices)), - } - - // Add HTTP services - services.Services = append(services.Services, httpServices...) - - // Add TCP services - services.Services = append(services.Services, tcpServices...) - - // Add UDP services - services.Services = append(services.Services, udpServices...) - - log.Printf("Fetched %d total services from Traefik API (%d HTTP, %d TCP, %d UDP)", - len(services.Services), len(httpServices), len(tcpServices), len(udpServices)) - - return services, nil -} + // Fetch HTTP services + httpServices, err := f.fetchHTTPServices(ctx, baseURL) + if err != nil { + return nil, fmt.Errorf("failed to fetch HTTP services: %w", err) + } + + // Try to fetch TCP services if available + tcpServices, err := f.fetchTCPServices(ctx, baseURL) + if err != nil { + // Log but don't fail - TCP services are optional + log.Printf("Warning: Failed to fetch TCP services: %v", err) + } + // Try to fetch UDP services if available (may not be supported in all Traefik versions) + udpServices, err := f.fetchUDPServices(ctx, baseURL) + if err != nil { + // Log but don't fail - UDP services are optional + log.Printf("Warning: Failed to fetch UDP services: %v", err) + } + + // Combine all services + services := &models.ServiceCollection{ + Services: make([]models.Service, 0, len(httpServices)+len(tcpServices)+len(udpServices)), + } + + // Add HTTP services + services.Services = append(services.Services, httpServices...) + + // Add TCP services + services.Services = append(services.Services, tcpServices...) + + // Add UDP services + services.Services = append(services.Services, udpServices...) + + log.Printf("Fetched %d total services from Traefik API (%d HTTP, %d TCP, %d UDP)", + len(services.Services), len(httpServices), len(tcpServices), len(udpServices)) + + return services, nil +} // Update the fetchHTTPServices function with these changes: func (f *TraefikServiceFetcher) fetchHTTPServices(ctx context.Context, baseURL string) ([]models.Service, error) { - // Create HTTP request to fetch HTTP services - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/http/services", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add basic auth if configured - if f.config.BasicAuth.Username != "" { - req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) - } - - // Execute request - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // Read and parse response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // First try to parse as an array of services - var traefikServicesArray []models.TraefikService - err = json.Unmarshal(body, &traefikServicesArray) - - services := make([]models.Service, 0) - - if err == nil { - // Successfully parsed as array - for _, traefikService := range traefikServicesArray { - // Skip internal services - if traefikService.Provider == "internal" { - continue - } - - // Process each service - service := processTraefikService(traefikService) - if service != nil { - services = append(services, *service) - } - } - } else { - // Try parsing as a map - var traefikServicesMap map[string]models.TraefikService - if jsonErr := json.Unmarshal(body, &traefikServicesMap); jsonErr != nil { - return nil, fmt.Errorf("failed to parse services JSON: %w", jsonErr) - } - - // Process each service in the map - for name, traefikService := range traefikServicesMap { - // Skip internal services - if traefikService.Provider == "internal" { - continue - } - - // Set the name from the map key - traefikService.Name = name - - // Process the service - service := processTraefikService(traefikService) - if service != nil { - services = append(services, *service) - } - } - } - - return services, nil + // Create HTTP request to fetch HTTP services + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/http/services", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add basic auth if configured + if f.config.BasicAuth.Username != "" { + req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) + } + + // Execute request + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Read and parse response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // First try to parse as an array of services + var traefikServicesArray []models.TraefikService + err = json.Unmarshal(body, &traefikServicesArray) + + services := make([]models.Service, 0) + + if err == nil { + // Successfully parsed as array + for _, traefikService := range traefikServicesArray { + // Skip internal services + if traefikService.Provider == "internal" { + continue + } + + // Process each service + service := processTraefikService(traefikService) + if service != nil { + services = append(services, *service) + } + } + } else { + // Try parsing as a map + var traefikServicesMap map[string]models.TraefikService + if jsonErr := json.Unmarshal(body, &traefikServicesMap); jsonErr != nil { + return nil, fmt.Errorf("failed to parse services JSON: %w", jsonErr) + } + + // Process each service in the map + for name, traefikService := range traefikServicesMap { + // Skip internal services + if traefikService.Provider == "internal" { + continue + } + + // Set the name from the map key + traefikService.Name = name + + // Process the service + service := processTraefikService(traefikService) + if service != nil { + services = append(services, *service) + } + } + } + + return services, nil } // fetchTCPServices fetches TCP services from Traefik API func (f *TraefikServiceFetcher) fetchTCPServices(ctx context.Context, baseURL string) ([]models.Service, error) { - // Create HTTP request to fetch TCP services - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/tcp/services", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add basic auth if configured - if f.config.BasicAuth.Username != "" { - req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) - } - - // Execute request - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // Read and parse response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Parse response (similar to HTTP services but adapt for TCP) - // This is a simplified implementation - would need to adapt to actual TCP service structure - - services := make([]models.Service, 0) - - // Try parsing as an array first - var tcpServicesArray []interface{} - err = json.Unmarshal(body, &tcpServicesArray) - - if err == nil { - // Successfully parsed as array - for i, tcpService := range tcpServicesArray { - serviceMap, ok := tcpService.(map[string]interface{}) - if !ok { - continue - } - - // Skip internal services - provider, _ := serviceMap["provider"].(string) - if provider == "internal" { - continue - } - - name, _ := serviceMap["name"].(string) - if name == "" { - name = fmt.Sprintf("tcp-service-%d", i) - } - - // Extract loadBalancer config - var config map[string]interface{} - if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { - config = lb - } else { - // Try other service types if needed - config = serviceMap - } - - // Create service - configJSON, _ := json.Marshal(config) - - services = append(services, models.Service{ - ID: name, - Name: name, - Type: string(models.LoadBalancerType), // Most TCP services are loadBalancers - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }) - } - } else { - // Try parsing as a map - var tcpServicesMap map[string]interface{} - if jsonErr := json.Unmarshal(body, &tcpServicesMap); jsonErr != nil { - return nil, fmt.Errorf("failed to parse TCP services JSON: %w", jsonErr) - } - - // Process each service in the map - for name, tcpService := range tcpServicesMap { - serviceMap, ok := tcpService.(map[string]interface{}) - if !ok { - continue - } - - // Skip internal services - provider, _ := serviceMap["provider"].(string) - if provider == "internal" { - continue - } - - // Extract loadBalancer config - var config map[string]interface{} - if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { - config = lb - } else { - // Try other service types if needed - config = serviceMap - } - - // Create service - configJSON, _ := json.Marshal(config) - - services = append(services, models.Service{ - ID: name, - Name: name, - Type: string(models.LoadBalancerType), // Most TCP services are loadBalancers - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }) - } - } - - return services, nil + // Create HTTP request to fetch TCP services + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/tcp/services", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add basic auth if configured + if f.config.BasicAuth.Username != "" { + req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) + } + + // Execute request + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Read and parse response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response (similar to HTTP services but adapt for TCP) + // This is a simplified implementation - would need to adapt to actual TCP service structure + + services := make([]models.Service, 0) + + // Try parsing as an array first + var tcpServicesArray []interface{} + err = json.Unmarshal(body, &tcpServicesArray) + + if err == nil { + // Successfully parsed as array + for i, tcpService := range tcpServicesArray { + serviceMap, ok := tcpService.(map[string]interface{}) + if !ok { + continue + } + + // Skip internal services + provider, _ := serviceMap["provider"].(string) + if provider == "internal" { + continue + } + + name, _ := serviceMap["name"].(string) + if name == "" { + name = fmt.Sprintf("tcp-service-%d", i) + } + + // Extract loadBalancer config + var config map[string]interface{} + if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { + config = lb + } else { + // Try other service types if needed + config = serviceMap + } + + // Create service + configJSON, _ := json.Marshal(config) + + services = append(services, models.Service{ + ID: name, + Name: name, + Type: string(models.LoadBalancerType), // Most TCP services are loadBalancers + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + } + } else { + // Try parsing as a map + var tcpServicesMap map[string]interface{} + if jsonErr := json.Unmarshal(body, &tcpServicesMap); jsonErr != nil { + return nil, fmt.Errorf("failed to parse TCP services JSON: %w", jsonErr) + } + + // Process each service in the map + for name, tcpService := range tcpServicesMap { + serviceMap, ok := tcpService.(map[string]interface{}) + if !ok { + continue + } + + // Skip internal services + provider, _ := serviceMap["provider"].(string) + if provider == "internal" { + continue + } + + // Extract loadBalancer config + var config map[string]interface{} + if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { + config = lb + } else { + // Try other service types if needed + config = serviceMap + } + + // Create service + configJSON, _ := json.Marshal(config) + + services = append(services, models.Service{ + ID: name, + Name: name, + Type: string(models.LoadBalancerType), // Most TCP services are loadBalancers + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + } + } + + return services, nil } // fetchUDPServices fetches UDP services from Traefik API func (f *TraefikServiceFetcher) fetchUDPServices(ctx context.Context, baseURL string) ([]models.Service, error) { - // Create HTTP request to fetch UDP services - req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/udp/services", nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // Add basic auth if configured - if f.config.BasicAuth.Username != "" { - req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) - } - - // Execute request - resp, err := f.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - // Check status code - if resp.StatusCode != http.StatusOK { - // Some Traefik versions may not support UDP services - if resp.StatusCode == http.StatusNotFound { - return []models.Service{}, nil - } - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // Read and parse response body - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - // Similar to TCP services but adapted for UDP - services := make([]models.Service, 0) - - // Try parsing as an array first - var udpServicesArray []interface{} - err = json.Unmarshal(body, &udpServicesArray) - - if err == nil { - // Successfully parsed as array - for i, udpService := range udpServicesArray { - serviceMap, ok := udpService.(map[string]interface{}) - if !ok { - continue - } - - // Skip internal services - provider, _ := serviceMap["provider"].(string) - if provider == "internal" { - continue - } - - name, _ := serviceMap["name"].(string) - if name == "" { - name = fmt.Sprintf("udp-service-%d", i) - } - - // Extract loadBalancer config - var config map[string]interface{} - if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { - config = lb - } else { - // Try other service types if needed - config = serviceMap - } - - // Create service - configJSON, _ := json.Marshal(config) - - services = append(services, models.Service{ - ID: name, - Name: name, - Type: string(models.LoadBalancerType), // Most UDP services are loadBalancers - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }) - } - } else { - // Try parsing as a map - var udpServicesMap map[string]interface{} - if jsonErr := json.Unmarshal(body, &udpServicesMap); jsonErr != nil { - return nil, fmt.Errorf("failed to parse UDP services JSON: %w", jsonErr) - } - - // Process each service in the map - for name, udpService := range udpServicesMap { - serviceMap, ok := udpService.(map[string]interface{}) - if !ok { - continue - } - - // Skip internal services - provider, _ := serviceMap["provider"].(string) - if provider == "internal" { - continue - } - - // Extract loadBalancer config - var config map[string]interface{} - if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { - config = lb - } else { - // Try other service types if needed - config = serviceMap - } - - // Create service - configJSON, _ := json.Marshal(config) - - services = append(services, models.Service{ - ID: name, - Name: name, - Type: string(models.LoadBalancerType), // Most UDP services are loadBalancers - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - }) - } - } - - return services, nil + // Create HTTP request to fetch UDP services + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/udp/services", nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // Add basic auth if configured + if f.config.BasicAuth.Username != "" { + req.SetBasicAuth(f.config.BasicAuth.Username, f.config.BasicAuth.Password) + } + + // Execute request + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + // Some Traefik versions may not support UDP services + if resp.StatusCode == http.StatusNotFound { + return []models.Service{}, nil + } + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + // Read and parse response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Similar to TCP services but adapted for UDP + services := make([]models.Service, 0) + + // Try parsing as an array first + var udpServicesArray []interface{} + err = json.Unmarshal(body, &udpServicesArray) + + if err == nil { + // Successfully parsed as array + for i, udpService := range udpServicesArray { + serviceMap, ok := udpService.(map[string]interface{}) + if !ok { + continue + } + + // Skip internal services + provider, _ := serviceMap["provider"].(string) + if provider == "internal" { + continue + } + + name, _ := serviceMap["name"].(string) + if name == "" { + name = fmt.Sprintf("udp-service-%d", i) + } + + // Extract loadBalancer config + var config map[string]interface{} + if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { + config = lb + } else { + // Try other service types if needed + config = serviceMap + } + + // Create service + configJSON, _ := json.Marshal(config) + + services = append(services, models.Service{ + ID: name, + Name: name, + Type: string(models.LoadBalancerType), // Most UDP services are loadBalancers + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + } + } else { + // Try parsing as a map + var udpServicesMap map[string]interface{} + if jsonErr := json.Unmarshal(body, &udpServicesMap); jsonErr != nil { + return nil, fmt.Errorf("failed to parse UDP services JSON: %w", jsonErr) + } + + // Process each service in the map + for name, udpService := range udpServicesMap { + serviceMap, ok := udpService.(map[string]interface{}) + if !ok { + continue + } + + // Skip internal services + provider, _ := serviceMap["provider"].(string) + if provider == "internal" { + continue + } + + // Extract loadBalancer config + var config map[string]interface{} + if lb, ok := serviceMap["loadBalancer"].(map[string]interface{}); ok { + config = lb + } else { + // Try other service types if needed + config = serviceMap + } + + // Create service + configJSON, _ := json.Marshal(config) + + services = append(services, models.Service{ + ID: name, + Name: name, + Type: string(models.LoadBalancerType), // Most UDP services are loadBalancers + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + } + } + + return services, nil } // processTraefikService extracts service information from Traefik API response // Update the processTraefikService function signature and implementation: func processTraefikService(traefikService models.TraefikService) *models.Service { - // Skip system services - if isTraefikSystemService(traefikService.Name) { - return nil - } - - // Determine service type and extract config - serviceType := string(models.LoadBalancerType) // Default - var config map[string]interface{} - - if traefikService.LoadBalancer != nil { - // Most common case: LoadBalancer - config = make(map[string]interface{}) - - // Extract servers - if traefikService.LoadBalancer.Servers != nil { - config["servers"] = traefikService.LoadBalancer.Servers - } - - // Extract other loadbalancer properties - if traefikService.LoadBalancer.PassHostHeader != nil { - config["passHostHeader"] = traefikService.LoadBalancer.PassHostHeader - } - - if traefikService.LoadBalancer.Sticky != nil { - config["sticky"] = traefikService.LoadBalancer.Sticky - } - - if traefikService.LoadBalancer.HealthCheck != nil { - config["healthCheck"] = traefikService.LoadBalancer.HealthCheck - } - } else if traefikService.Weighted != nil { - // Weighted service - serviceType = string(models.WeightedType) - config = make(map[string]interface{}) - - // Extract weighted service properties - if traefikService.Weighted.Services != nil { - config["services"] = traefikService.Weighted.Services - } - - if traefikService.Weighted.Sticky != nil { - config["sticky"] = traefikService.Weighted.Sticky - } - - if traefikService.Weighted.HealthCheck != nil { - config["healthCheck"] = traefikService.Weighted.HealthCheck - } - } else if traefikService.Mirroring != nil { - // Mirroring service - serviceType = string(models.MirroringType) - config = make(map[string]interface{}) - - // Extract mirroring service properties - if traefikService.Mirroring.Service != "" { - config["service"] = traefikService.Mirroring.Service - } - - if traefikService.Mirroring.Mirrors != nil { - config["mirrors"] = traefikService.Mirroring.Mirrors - } - - if traefikService.Mirroring.MaxBodySize != nil { - config["maxBodySize"] = traefikService.Mirroring.MaxBodySize - } - - if traefikService.Mirroring.MirrorBody != nil { - config["mirrorBody"] = traefikService.Mirroring.MirrorBody - } - - if traefikService.Mirroring.HealthCheck != nil { - config["healthCheck"] = traefikService.Mirroring.HealthCheck - } - } else if traefikService.Failover != nil { - // Failover service - serviceType = string(models.FailoverType) - config = make(map[string]interface{}) - - // Extract failover service properties - if traefikService.Failover.Service != "" { - config["service"] = traefikService.Failover.Service - } - - if traefikService.Failover.Fallback != "" { - config["fallback"] = traefikService.Failover.Fallback - } - - if traefikService.Failover.HealthCheck != nil { - config["healthCheck"] = traefikService.Failover.HealthCheck - } - } else { - // Unknown service type or empty config - config = make(map[string]interface{}) - } - - // Convert config to JSON - configJSON, err := json.Marshal(config) - if err != nil { - log.Printf("Error marshaling service config: %v", err) - configJSON = []byte("{}") - } - - return &models.Service{ - ID: traefikService.Name, - Name: traefikService.Name, - Type: serviceType, - Config: string(configJSON), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } + // Skip system services + if isTraefikSystemService(traefikService.Name) { + return nil + } + + // Determine service type and extract config + serviceType := string(models.LoadBalancerType) // Default + var config map[string]interface{} + + if traefikService.LoadBalancer != nil { + // Most common case: LoadBalancer + config = make(map[string]interface{}) + + // Extract servers + if traefikService.LoadBalancer.Servers != nil { + config["servers"] = traefikService.LoadBalancer.Servers + } + + // Extract other loadbalancer properties + if traefikService.LoadBalancer.PassHostHeader != nil { + config["passHostHeader"] = traefikService.LoadBalancer.PassHostHeader + } + + if traefikService.LoadBalancer.Sticky != nil { + config["sticky"] = traefikService.LoadBalancer.Sticky + } + + if traefikService.LoadBalancer.HealthCheck != nil { + config["healthCheck"] = traefikService.LoadBalancer.HealthCheck + } + } else if traefikService.Weighted != nil { + // Weighted service + serviceType = string(models.WeightedType) + config = make(map[string]interface{}) + + // Extract weighted service properties + if traefikService.Weighted.Services != nil { + config["services"] = traefikService.Weighted.Services + } + + if traefikService.Weighted.Sticky != nil { + config["sticky"] = traefikService.Weighted.Sticky + } + + if traefikService.Weighted.HealthCheck != nil { + config["healthCheck"] = traefikService.Weighted.HealthCheck + } + } else if traefikService.Mirroring != nil { + // Mirroring service + serviceType = string(models.MirroringType) + config = make(map[string]interface{}) + + // Extract mirroring service properties + if traefikService.Mirroring.Service != "" { + config["service"] = traefikService.Mirroring.Service + } + + if traefikService.Mirroring.Mirrors != nil { + config["mirrors"] = traefikService.Mirroring.Mirrors + } + + if traefikService.Mirroring.MaxBodySize != nil { + config["maxBodySize"] = traefikService.Mirroring.MaxBodySize + } + + if traefikService.Mirroring.MirrorBody != nil { + config["mirrorBody"] = traefikService.Mirroring.MirrorBody + } + + if traefikService.Mirroring.HealthCheck != nil { + config["healthCheck"] = traefikService.Mirroring.HealthCheck + } + } else if traefikService.Failover != nil { + // Failover service + serviceType = string(models.FailoverType) + config = make(map[string]interface{}) + + // Extract failover service properties + if traefikService.Failover.Service != "" { + config["service"] = traefikService.Failover.Service + } + + if traefikService.Failover.Fallback != "" { + config["fallback"] = traefikService.Failover.Fallback + } + + if traefikService.Failover.HealthCheck != nil { + config["healthCheck"] = traefikService.Failover.HealthCheck + } + } else { + // Unknown service type or empty config + config = make(map[string]interface{}) + } + + // Convert config to JSON + configJSON, err := json.Marshal(config) + if err != nil { + log.Printf("Error marshaling service config: %v", err) + configJSON = []byte("{}") + } + + return &models.Service{ + ID: traefikService.Name, + Name: traefikService.Name, + Type: serviceType, + Config: string(configJSON), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } } // suggestURLUpdate logs a message suggesting the URL should be updated func (f *TraefikServiceFetcher) suggestURLUpdate(workingURL string) { - log.Printf("IMPORTANT: Consider updating the Traefik API URL to %s in the settings", workingURL) + log.Printf("IMPORTANT: Consider updating the Traefik API URL to %s in the settings", workingURL) } // isTraefikSystemService checks if a service is a Traefik system service (to be skipped) func isTraefikSystemService(serviceID string) bool { - systemPrefixes := []string{ - "api@internal", - "dashboard@internal", - "noop@internal", - "acme-http@internal", - } - - for _, prefix := range systemPrefixes { - if strings.Contains(serviceID, prefix) { - return true - } - } - - return false -} \ No newline at end of file + systemPrefixes := []string{ + "api@internal", + "dashboard@internal", + "noop@internal", + "acme-http@internal", + } + + for _, prefix := range systemPrefixes { + if strings.Contains(serviceID, prefix) { + return true + } + } + + return false +} diff --git a/services/service_watcher.go b/services/service_watcher.go index b2f4b41e..34b771ff 100644 --- a/services/service_watcher.go +++ b/services/service_watcher.go @@ -1,546 +1,548 @@ package services import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "log" - "strings" - "time" - - "github.com/hhftechnology/middleware-manager/database" - "github.com/hhftechnology/middleware-manager/models" - "github.com/hhftechnology/middleware-manager/util" + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/hhftechnology/middleware-manager/database" + "github.com/hhftechnology/middleware-manager/models" + "github.com/hhftechnology/middleware-manager/util" ) // ServiceWatcher watches for services using configured data source type ServiceWatcher struct { - db *database.DB - fetcher ServiceFetcher - configManager *ConfigManager - stopChan chan struct{} - isRunning bool + db *database.DB + fetcher ServiceFetcher + configManager *ConfigManager + stopChan chan struct{} + isRunning bool } // NewServiceWatcher creates a new service watcher func NewServiceWatcher(db *database.DB, configManager *ConfigManager) (*ServiceWatcher, error) { - // Get the active data source config - dsConfig, err := configManager.GetActiveDataSourceConfig() - if err != nil { - return nil, fmt.Errorf("failed to get active data source config: %w", err) - } - - // Create the fetcher - fetcher, err := NewServiceFetcher(dsConfig) - if err != nil { - return nil, fmt.Errorf("failed to create service fetcher: %w", err) - } - - return &ServiceWatcher{ - db: db, - fetcher: fetcher, - configManager: configManager, - stopChan: make(chan struct{}), - isRunning: false, - }, nil + // Get the active data source config + dsConfig, err := configManager.GetActiveDataSourceConfig() + if err != nil { + return nil, fmt.Errorf("failed to get active data source config: %w", err) + } + + // Create the fetcher + fetcher, err := NewServiceFetcher(dsConfig) + if err != nil { + return nil, fmt.Errorf("failed to create service fetcher: %w", err) + } + + return &ServiceWatcher{ + db: db, + fetcher: fetcher, + configManager: configManager, + stopChan: make(chan struct{}), + isRunning: false, + }, nil } // Start begins watching for services func (sw *ServiceWatcher) Start(interval time.Duration) { - if sw.isRunning { - return - } - - sw.isRunning = true - log.Printf("Service watcher started, checking every %v", interval) - - ticker := time.NewTicker(interval) - defer ticker.Stop() - - // Do an initial check - if err := sw.checkServices(); err != nil { - log.Printf("Initial service check failed: %v", err) - } - - for { - select { - case <-ticker.C: - // Check if data source config has changed - if err := sw.refreshFetcher(); err != nil { - log.Printf("Failed to refresh service fetcher: %v", err) - } - - if err := sw.checkServices(); err != nil { - log.Printf("Service check failed: %v", err) - } - case <-sw.stopChan: - log.Println("Service watcher stopped") - return - } - } + if sw.isRunning { + return + } + + sw.isRunning = true + log.Printf("Service watcher started, checking every %v", interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Do an initial check + if err := sw.checkServices(); err != nil { + log.Printf("Initial service check failed: %v", err) + } + + for { + select { + case <-ticker.C: + // Check if data source config has changed + if err := sw.refreshFetcher(); err != nil { + log.Printf("Failed to refresh service fetcher: %v", err) + } + + if err := sw.checkServices(); err != nil { + log.Printf("Service check failed: %v", err) + } + case <-sw.stopChan: + log.Println("Service watcher stopped") + return + } + } } // refreshFetcher updates the fetcher if the data source config has changed func (sw *ServiceWatcher) refreshFetcher() error { - dsConfig, err := sw.configManager.GetActiveDataSourceConfig() - if err != nil { - return fmt.Errorf("failed to get data source config: %w", err) - } - - // Create a new fetcher with the updated config - fetcher, err := NewServiceFetcher(dsConfig) - if err != nil { - return fmt.Errorf("failed to create service fetcher: %w", err) - } - - // Update the fetcher - sw.fetcher = fetcher - return nil + dsConfig, err := sw.configManager.GetActiveDataSourceConfig() + if err != nil { + return fmt.Errorf("failed to get data source config: %w", err) + } + + // Create a new fetcher with the updated config + fetcher, err := NewServiceFetcher(dsConfig) + if err != nil { + return fmt.Errorf("failed to create service fetcher: %w", err) + } + + // Update the fetcher + sw.fetcher = fetcher + return nil } // Stop stops the service watcher func (sw *ServiceWatcher) Stop() { - if !sw.isRunning { - return - } - - close(sw.stopChan) - sw.isRunning = false + if !sw.isRunning { + return + } + + close(sw.stopChan) + sw.isRunning = false } // checkServices fetches services from the configured data source and updates the database func (sw *ServiceWatcher) checkServices() error { - log.Println("Checking for services using configured data source...") - - // Create a context with timeout for the operation - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Fetch services using the configured fetcher - services, err := sw.fetcher.FetchServices(ctx) - if err != nil { - return fmt.Errorf("failed to fetch services: %w", err) - } - - // Get all existing services from the database - var existingServices []string - rows, err := sw.db.Query("SELECT id FROM services") - if err != nil { - return fmt.Errorf("failed to query existing services: %w", err) - } - - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - log.Printf("Error scanning service ID: %v", err) - continue - } - existingServices = append(existingServices, id) - } - rows.Close() - - // Keep track of services we find - foundServices := make(map[string]bool) - - // Check if there are any services - if len(services.Services) == 0 { - log.Println("No services found in data source") - return nil - } - - // Process services - for _, service := range services.Services { - // Skip invalid services - if service.ID == "" || service.Type == "" { - continue - } - - // Process service - if err := sw.updateOrCreateService(service); err != nil { - log.Printf("Error processing service %s: %v", service.ID, err) - // Continue processing other services even if one fails - continue - } - - // Mark normalized version of this service as found - normalizedID := util.NormalizeID(service.ID) - foundServices[normalizedID] = true - } - - // Optionally, mark services as "inactive" if they no longer exist in the data source - // This is commented out by default to avoid deleting user-created services - /* - for _, serviceID := range existingServices { - normalizedID := util.NormalizeID(serviceID) - if !foundServices[normalizedID] { - log.Printf("Service %s no longer exists in data source, consider marking as inactive", serviceID) - // Optional: You could update a status field if you add one to the services table - // _, err := sw.db.Exec("UPDATE services SET status = 'inactive' WHERE id = ?", serviceID) - } - } - */ - - return nil + + // Create a context with timeout for the operation + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Fetch services using the configured fetcher + services, err := sw.fetcher.FetchServices(ctx) + if err != nil { + return fmt.Errorf("failed to fetch services: %w", err) + } + + // Get all existing active Pangolin-synced services from the database + // We only track Pangolin-synced services for cleanup (source_type = 'pangolin') + var existingServices []string + rows, err := sw.db.Query("SELECT id FROM services WHERE status = 'active' AND source_type = 'pangolin'") + if err != nil { + return fmt.Errorf("failed to query existing services: %w", err) + } + + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + log.Printf("Error scanning service ID: %v", err) + continue + } + existingServices = append(existingServices, id) + } + rows.Close() + + // Keep track of services we find + foundServices := make(map[string]bool) + + // Check if there are any services + if len(services.Services) == 0 { + log.Println("No services found in data source") + return nil + } + + // Process services + for _, service := range services.Services { + // Skip invalid services + if service.ID == "" || service.Type == "" { + continue + } + + // Process service + if err := sw.updateOrCreateService(service); err != nil { + log.Printf("Error processing service %s: %v", service.ID, err) + // Continue processing other services even if one fails + continue + } + + // Mark normalized version of this service as found + normalizedID := util.NormalizeID(service.ID) + foundServices[normalizedID] = true + } + + // Mark Pangolin-synced services as disabled if they no longer exist in the data source + // Only affects services with source_type = 'pangolin' (already filtered in the query above) + for _, serviceID := range existingServices { + normalizedID := util.NormalizeID(serviceID) + if !foundServices[normalizedID] { + log.Printf("Service %s no longer exists in Pangolin, marking as disabled", serviceID) + _, err := sw.db.Exec( + "UPDATE services SET status = 'disabled', updated_at = ? WHERE id = ?", + time.Now(), serviceID, + ) + if err != nil { + log.Printf("Error marking service as disabled: %v", err) + } + } + } + + return nil } // updateOrCreateService updates an existing service or creates a new one func (sw *ServiceWatcher) updateOrCreateService(service models.Service) error { - // Use our centralized normalization function - normalizedID := util.NormalizeID(service.ID) - originalID := service.ID - - // Check if service already exists using normalized ID - var exists int - var existingType, existingConfig string - - err := sw.db.QueryRow( - "SELECT 1, type, config FROM services WHERE id = ?", - normalizedID, - ).Scan(&exists, &existingType, &existingConfig) - - if err == nil { - // Service exists, only update if it changed - if shouldUpdateService(sw.db, service, normalizedID) { - log.Printf("Updating existing service: %s (normalized from %s)", normalizedID, originalID) - return sw.updateService(service, normalizedID) - } - // Service exists and hasn't changed, skip update - return nil - } else if err != sql.ErrNoRows { - // Unexpected error - return fmt.Errorf("error checking if service exists: %w", err) - } - - // Try checking if service exists with different provider suffixes - var found bool - err = sw.db.QueryRow( - "SELECT 1 FROM services WHERE id LIKE ?", - normalizedID+"%", - ).Scan(&exists) - - if err == nil { - // Found a service with this base name but different suffix - found = true - var altID string - err = sw.db.QueryRow( - "SELECT id FROM services WHERE id LIKE ? LIMIT 1", - normalizedID+"%", - ).Scan(&altID) - - if err == nil { - log.Printf("Found existing service with different suffix: %s - will update", altID) - return sw.updateService(service, altID) - } - } - - if !found { - // Service doesn't exist with any suffix, create it - service.ID = normalizedID - return sw.createService(service) - } - - // This shouldn't be reached, but just in case - return nil + // Use our centralized normalization function + normalizedID := util.NormalizeID(service.ID) + originalID := service.ID + + // Check if service already exists using normalized ID + var exists int + var existingType, existingConfig string + + err := sw.db.QueryRow( + "SELECT 1, type, config FROM services WHERE id = ?", + normalizedID, + ).Scan(&exists, &existingType, &existingConfig) + + if err == nil { + // Service exists, only update if it changed + if shouldUpdateService(sw.db, service, normalizedID) { + log.Printf("Updating existing service: %s (normalized from %s)", normalizedID, originalID) + return sw.updateService(service, normalizedID) + } + // Service exists and hasn't changed, skip update + return nil + } else if err != sql.ErrNoRows { + // Unexpected error + return fmt.Errorf("error checking if service exists: %w", err) + } + + // Try checking if service exists with different provider suffixes + var found bool + err = sw.db.QueryRow( + "SELECT 1 FROM services WHERE id LIKE ?", + normalizedID+"%", + ).Scan(&exists) + + if err == nil { + // Found a service with this base name but different suffix + found = true + var altID string + err = sw.db.QueryRow( + "SELECT id FROM services WHERE id LIKE ? LIMIT 1", + normalizedID+"%", + ).Scan(&altID) + + if err == nil { + // Check if update is actually needed before updating + if shouldUpdateService(sw.db, service, altID) { + log.Printf("Updating service with different suffix: %s", altID) + return sw.updateService(service, altID) + } + // Service exists and hasn't changed, skip update + return nil + } + } + + if !found { + // Service doesn't exist with any suffix, create it + service.ID = normalizedID + return sw.createService(service) + } + + // This shouldn't be reached, but just in case + return nil } // shouldUpdateService determines if an existing service needs to be updated func shouldUpdateService(db *database.DB, newService models.Service, normalizedID string) bool { - var existingType, existingConfig string - - err := db.QueryRow( - "SELECT type, config FROM services WHERE id = ?", - normalizedID, - ).Scan(&existingType, &existingConfig) - - if err != nil { - // If there's an error, assume we should update - log.Printf("Error checking existing service %s: %v", normalizedID, err) - return true - } - - // Check if the type has changed - if existingType != newService.Type { - return true - } - - // Check if the configuration has changed - // Parse both configs to compare them semantically - var existingConfigMap map[string]interface{} - var newConfigMap map[string]interface{} - - if err := json.Unmarshal([]byte(existingConfig), &existingConfigMap); err != nil { - log.Printf("Error parsing existing config for %s: %v", normalizedID, err) - return true - } - - if err := json.Unmarshal([]byte(newService.Config), &newConfigMap); err != nil { - log.Printf("Error parsing new config for %s: %v", normalizedID, err) - return true - } - - // Compare the configurations - return configsAreDifferent(existingConfigMap, newConfigMap) + var existingType, existingConfig string + + err := db.QueryRow( + "SELECT type, config FROM services WHERE id = ?", + normalizedID, + ).Scan(&existingType, &existingConfig) + + if err != nil { + // If there's an error, assume we should update + log.Printf("Error checking existing service %s: %v", normalizedID, err) + return true + } + + // Check if the type has changed + if existingType != newService.Type { + return true + } + + // Check if the configuration has changed + // Parse both configs to compare them semantically + var existingConfigMap map[string]interface{} + var newConfigMap map[string]interface{} + + if err := json.Unmarshal([]byte(existingConfig), &existingConfigMap); err != nil { + log.Printf("Error parsing existing config for %s: %v", normalizedID, err) + return true + } + + if err := json.Unmarshal([]byte(newService.Config), &newConfigMap); err != nil { + log.Printf("Error parsing new config for %s: %v", normalizedID, err) + return true + } + + // Compare the configurations + return configsAreDifferent(existingConfigMap, newConfigMap) } // configsAreDifferent compares two service configurations func configsAreDifferent(config1, config2 map[string]interface{}) bool { - // Check for key differences - for key := range config1 { - if _, exists := config2[key]; !exists { - return true - } - } - - for key := range config2 { - if _, exists := config1[key]; !exists { - return true - } - } - - // Check server configurations - servers1, hasServers1 := config1["servers"].([]interface{}) - servers2, hasServers2 := config2["servers"].([]interface{}) - - if hasServers1 != hasServers2 { - return true - } - - if hasServers1 && hasServers2 { - if len(servers1) != len(servers2) { - return true - } - - // Compare each server - for i, server1 := range servers1 { - if i >= len(servers2) { - return true - } - - server1Map, ok1 := server1.(map[string]interface{}) - server2Map, ok2 := servers2[i].(map[string]interface{}) - - if !ok1 || !ok2 { - return true - } - - // Check URL/address fields - url1, hasURL1 := server1Map["url"].(string) - url2, hasURL2 := server2Map["url"].(string) - - if hasURL1 != hasURL2 || (hasURL1 && url1 != url2) { - return true - } - - addr1, hasAddr1 := server1Map["address"].(string) - addr2, hasAddr2 := server2Map["address"].(string) - - if hasAddr1 != hasAddr2 || (hasAddr1 && addr1 != addr2) { - return true - } - } - } - - // For other service types, we would need to check specific fields - // For simplicity, we'll consider them different if any common key has a different value - for key, val1 := range config1 { - if val2, exists := config2[key]; exists { - // Skip servers as we've handled them above - if key == "servers" { - continue - } - - // Handle primitive types - switch v1 := val1.(type) { - case string: - v2, ok := val2.(string) - if !ok || v1 != v2 { - return true - } - case float64: - v2, ok := val2.(float64) - if !ok || v1 != v2 { - return true - } - case bool: - v2, ok := val2.(bool) - if !ok || v1 != v2 { - return true - } - } - } - } - - return false + // Check for key differences + for key := range config1 { + if _, exists := config2[key]; !exists { + return true + } + } + + for key := range config2 { + if _, exists := config1[key]; !exists { + return true + } + } + + // Check server configurations + servers1, hasServers1 := config1["servers"].([]interface{}) + servers2, hasServers2 := config2["servers"].([]interface{}) + + if hasServers1 != hasServers2 { + return true + } + + if hasServers1 && hasServers2 { + if len(servers1) != len(servers2) { + return true + } + + // Compare each server + for i, server1 := range servers1 { + if i >= len(servers2) { + return true + } + + server1Map, ok1 := server1.(map[string]interface{}) + server2Map, ok2 := servers2[i].(map[string]interface{}) + + if !ok1 || !ok2 { + return true + } + + // Check URL/address fields + url1, hasURL1 := server1Map["url"].(string) + url2, hasURL2 := server2Map["url"].(string) + + if hasURL1 != hasURL2 || (hasURL1 && url1 != url2) { + return true + } + + addr1, hasAddr1 := server1Map["address"].(string) + addr2, hasAddr2 := server2Map["address"].(string) + + if hasAddr1 != hasAddr2 || (hasAddr1 && addr1 != addr2) { + return true + } + } + } + + // For other service types, we would need to check specific fields + // For simplicity, we'll consider them different if any common key has a different value + for key, val1 := range config1 { + if val2, exists := config2[key]; exists { + // Skip servers as we've handled them above + if key == "servers" { + continue + } + + // Handle primitive types + switch v1 := val1.(type) { + case string: + v2, ok := val2.(string) + if !ok || v1 != v2 { + return true + } + case float64: + v2, ok := val2.(float64) + if !ok || v1 != v2 { + return true + } + case bool: + v2, ok := val2.(bool) + if !ok || v1 != v2 { + return true + } + } + } + } + + return false } // createService creates a new service in the database func (sw *ServiceWatcher) createService(service models.Service) error { - // Validate service type - if !models.IsValidServiceType(service.Type) { - // Try to determine proper type if it's invalid - if strings.Contains(strings.ToLower(service.Type), "load") || - strings.Contains(service.Config, "servers") { - service.Type = string(models.LoadBalancerType) - } else if strings.Contains(strings.ToLower(service.Type), "weight") { - service.Type = string(models.WeightedType) - } else if strings.Contains(strings.ToLower(service.Type), "mirror") { - service.Type = string(models.MirroringType) - } else if strings.Contains(strings.ToLower(service.Type), "fail") { - service.Type = string(models.FailoverType) - } else { - // Default to LoadBalancer if we can't determine - service.Type = string(models.LoadBalancerType) - } - } - - // Process the service configuration - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(service.Config), &configMap); err != nil { - log.Printf("Error parsing service config for %s: %v, using empty config", service.ID, err) - configMap = make(map[string]interface{}) - } - - // Apply any service-specific processing - configMap = models.ProcessServiceConfig(service.Type, configMap) - - // Convert processed config back to JSON - configJSON, err := json.Marshal(configMap) - if err != nil { - log.Printf("Error marshaling processed config for %s: %v", service.ID, err) - configJSON = []byte("{}") - } - - // Create a reasonable name if none provided - if service.Name == "" { - service.Name = formatServiceName(service.ID) - } - - // Get active data source to determine provider suffix - dsConfig, err := sw.configManager.GetActiveDataSourceConfig() - if err != nil { - log.Printf("Warning: Could not get active data source: %v. Using default file provider.", err) - dsConfig.Type = models.PangolinAPI - } - - // Determine the appropriate provider suffix based on context - providerSuffix := "@file" - if !strings.Contains(service.ID, "@") { - // Only add a suffix if one doesn't already exist - service.ID = service.ID + providerSuffix - } - - // Use a database transaction for insert - return sw.db.WithTransaction(func(tx *sql.Tx) error { - log.Printf("Creating new service: %s", service.ID) - - // Check for existing service one more time within transaction - var exists int - err := tx.QueryRow("SELECT 1 FROM services WHERE id = ?", service.ID).Scan(&exists) - if err == nil { - // Service exists, silently skip - return nil - } else if err != sql.ErrNoRows { - // Unexpected error - return fmt.Errorf("error checking service existence in transaction: %w", err) - } - - // Insert the service - _, err = tx.Exec( - "INSERT INTO services (id, name, type, config, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", - service.ID, service.Name, service.Type, string(configJSON), time.Now(), time.Now(), - ) - - if err != nil { - // Check if it's a duplicate key error - if strings.Contains(err.Error(), "UNIQUE constraint") { - // Log but don't return error to continue processing other services - log.Printf("Service %s already exists, skipping", service.ID) - return nil - } - return fmt.Errorf("failed to insert service %s: %w", service.ID, err) - } - - log.Printf("Created new service: %s", service.ID) - return nil - }) + // Validate service type + if !models.IsValidServiceType(service.Type) { + // Try to determine proper type if it's invalid + if strings.Contains(strings.ToLower(service.Type), "load") || + strings.Contains(service.Config, "servers") { + service.Type = string(models.LoadBalancerType) + } else if strings.Contains(strings.ToLower(service.Type), "weight") { + service.Type = string(models.WeightedType) + } else if strings.Contains(strings.ToLower(service.Type), "mirror") { + service.Type = string(models.MirroringType) + } else if strings.Contains(strings.ToLower(service.Type), "fail") { + service.Type = string(models.FailoverType) + } else { + // Default to LoadBalancer if we can't determine + service.Type = string(models.LoadBalancerType) + } + } + + // Process the service configuration + var configMap map[string]interface{} + if err := json.Unmarshal([]byte(service.Config), &configMap); err != nil { + log.Printf("Error parsing service config for %s: %v, using empty config", service.ID, err) + configMap = make(map[string]interface{}) + } + + // Apply any service-specific processing + configMap = models.ProcessServiceConfig(service.Type, configMap) + + // Convert processed config back to JSON + configJSON, err := json.Marshal(configMap) + if err != nil { + log.Printf("Error marshaling processed config for %s: %v", service.ID, err) + configJSON = []byte("{}") + } + + // Create a reasonable name if none provided + if service.Name == "" { + service.Name = formatServiceName(service.ID) + } + + // Get active data source to determine provider suffix + dsConfig, err := sw.configManager.GetActiveDataSourceConfig() + if err != nil { + log.Printf("Warning: Could not get active data source: %v. Using default file provider.", err) + dsConfig.Type = models.PangolinAPI + } + + // Use a database transaction for insert + return sw.db.WithTransaction(func(tx *sql.Tx) error { + log.Printf("Creating new service: %s", service.ID) + + // Check for existing service one more time within transaction + var exists int + err := tx.QueryRow("SELECT 1 FROM services WHERE id = ?", service.ID).Scan(&exists) + if err == nil { + // Service exists, silently skip + return nil + } else if err != sql.ErrNoRows { + // Unexpected error + return fmt.Errorf("error checking service existence in transaction: %w", err) + } + + // Insert the service with source_type for tracking origin + _, err = tx.Exec( + "INSERT INTO services (id, name, type, config, status, source_type, created_at, updated_at) VALUES (?, ?, ?, ?, 'active', 'pangolin', ?, ?)", + service.ID, service.Name, service.Type, string(configJSON), time.Now(), time.Now(), + ) + + if err != nil { + // Check if it's a duplicate key error + if strings.Contains(err.Error(), "UNIQUE constraint") { + // Log but don't return error to continue processing other services + log.Printf("Service %s already exists, skipping", service.ID) + return nil + } + return fmt.Errorf("failed to insert service %s: %w", service.ID, err) + } + + log.Printf("Created new service: %s", service.ID) + return nil + }) } // updateService updates an existing service in the database func (sw *ServiceWatcher) updateService(service models.Service, existingID string) error { - // Get the existing service to preserve the name - var existingName string - err := sw.db.QueryRow("SELECT name FROM services WHERE id = ?", existingID).Scan(&existingName) - - if err != nil { - log.Printf("Error fetching existing service name for %s: %v, using provided name", existingID, err) - } else if existingName != "" { - // Preserve existing name unless the new name is meaningful - if service.Name == service.ID || service.Name == "" { - service.Name = existingName - } - } - - // Process the service configuration - var configMap map[string]interface{} - if err := json.Unmarshal([]byte(service.Config), &configMap); err != nil { - log.Printf("Error parsing service config for %s: %v, using empty config", service.ID, err) - configMap = make(map[string]interface{}) - } - - // Apply any service-specific processing - configMap = models.ProcessServiceConfig(service.Type, configMap) - - // Convert processed config back to JSON - configJSON, err := json.Marshal(configMap) - if err != nil { - log.Printf("Error marshaling processed config for %s: %v", service.ID, err) - configJSON = []byte("{}") - } - - // Update the service using a transaction - return sw.db.WithTransaction(func(tx *sql.Tx) error { - // Update the service using the existing ID - result, err := tx.Exec( - "UPDATE services SET name = ?, type = ?, config = ?, updated_at = ? WHERE id = ?", - service.Name, service.Type, string(configJSON), time.Now(), existingID, - ) - - if err != nil { - return fmt.Errorf("failed to update service %s: %w", service.ID, err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - log.Printf("Error getting rows affected: %v", err) - } else if rowsAffected == 0 { - log.Printf("Warning: Update did not affect any rows for service %s", existingID) - } - - log.Printf("Updated existing service: %s", existingID) - return nil - }) + // Get the existing service to preserve the name + var existingName string + err := sw.db.QueryRow("SELECT name FROM services WHERE id = ?", existingID).Scan(&existingName) + + if err != nil { + log.Printf("Error fetching existing service name for %s: %v, using provided name", existingID, err) + } else if existingName != "" { + // Preserve existing name unless the new name is meaningful + if service.Name == service.ID || service.Name == "" { + service.Name = existingName + } + } + + // Process the service configuration + var configMap map[string]interface{} + if err := json.Unmarshal([]byte(service.Config), &configMap); err != nil { + log.Printf("Error parsing service config for %s: %v, using empty config", service.ID, err) + configMap = make(map[string]interface{}) + } + + // Apply any service-specific processing + configMap = models.ProcessServiceConfig(service.Type, configMap) + + // Convert processed config back to JSON + configJSON, err := json.Marshal(configMap) + if err != nil { + log.Printf("Error marshaling processed config for %s: %v", service.ID, err) + configJSON = []byte("{}") + } + + // Update the service using a transaction + return sw.db.WithTransaction(func(tx *sql.Tx) error { + // Update the service using the existing ID, ensure status is active and source_type is pangolin + result, err := tx.Exec( + "UPDATE services SET name = ?, type = ?, config = ?, status = 'active', source_type = 'pangolin', updated_at = ? WHERE id = ?", + service.Name, service.Type, string(configJSON), time.Now(), existingID, + ) + + if err != nil { + return fmt.Errorf("failed to update service %s: %w", service.ID, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + log.Printf("Error getting rows affected: %v", err) + } else if rowsAffected == 0 { + log.Printf("Warning: Update did not affect any rows for service %s", existingID) + } else { + log.Printf("Updated existing service: %s", existingID) + } + + return nil + }) } // formatServiceName creates a readable name from a service ID func formatServiceName(id string) string { - // Remove provider suffix if present - name := id - if idx := strings.Index(name, "@"); idx > 0 { - name = name[:idx] - } - - // Replace dashes and underscores with spaces - name = strings.ReplaceAll(name, "-", " ") - name = strings.ReplaceAll(name, "_", " ") - - // Capitalize words - parts := strings.Fields(name) - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(part[:1]) + part[1:] - } - } - - return strings.Join(parts, " ") -} \ No newline at end of file + // Remove provider suffix if present + name := id + if idx := strings.Index(name, "@"); idx > 0 { + name = name[:idx] + } + + // Replace dashes and underscores with spaces + name = strings.ReplaceAll(name, "-", " ") + name = strings.ReplaceAll(name, "_", " ") + + // Capitalize words + parts := strings.Fields(name) + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + + return strings.Join(parts, " ") +} diff --git a/services/traefik_fetcher.go b/services/traefik_fetcher.go index c6990a02..69d7ee7e 100644 --- a/services/traefik_fetcher.go +++ b/services/traefik_fetcher.go @@ -130,10 +130,10 @@ func (f *TraefikFetcher) fetchResourcesInternal(ctx context.Context) (*models.Re // Try common fallback URLs fallbackURLs := []string{ - "http://host.docker.internal:8080", + "http://traefik:8080", "http://localhost:8080", "http://127.0.0.1:8080", - "http://traefik:8080", + "http://host.docker.internal:8080", } // Don't try the same URL twice diff --git a/ui/src/components/resources/ResourceDetail.tsx b/ui/src/components/resources/ResourceDetail.tsx index a539e2bb..63e7ad08 100644 --- a/ui/src/components/resources/ResourceDetail.tsx +++ b/ui/src/components/resources/ResourceDetail.tsx @@ -572,30 +572,63 @@ export function ResourceDetail() { {selectedResource.service_id ? ( -
-
-

{selectedResource.service_id}

-

Currently assigned

-
-
- - + <> +
+
+

{selectedResource.service_id}

+

Currently assigned

+
+
+ + +
-
+ {/* Display server targets from the assigned service */} + {(() => { + const assignedService = services.find(s => s.id === selectedResource.service_id) + if (!assignedService) return null + + const config = assignedService.config as Record + const servers = config?.servers as Array<{ url?: string; address?: string; weight?: number }> | undefined + + if (!servers || servers.length === 0) return null + + return ( +
+ +
+ {servers.map((server, index) => ( +
+ {server.url || server.address || 'Unknown'} + {server.weight !== undefined && ( + + weight: {server.weight} + + )} +
+ ))} +
+
+ ) + })()} + ) : (