mirror of
https://github.com/hhftechnology/middleware-manager.git
synced 2025-12-29 04:09:36 -06:00
update
This commit is contained in:
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
@@ -1,7 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hhftechnology/middleware-manager/models"
|
||||
@@ -25,6 +29,12 @@ func (h *DataSourceHandler) GetDataSources(c *gin.Context) {
|
||||
sources := h.ConfigManager.GetDataSources()
|
||||
activeSource := h.ConfigManager.GetActiveSourceName()
|
||||
|
||||
// Format sources to mask passwords
|
||||
for key, source := range sources {
|
||||
source.FormatBasicAuth()
|
||||
sources[key] = source
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"active_source": activeSource,
|
||||
"sources": sources,
|
||||
@@ -91,4 +101,78 @@ func (h *DataSourceHandler) UpdateDataSource(c *gin.Context) {
|
||||
"name": name,
|
||||
"config": config,
|
||||
})
|
||||
}
|
||||
|
||||
// TestDataSourceConnection tests the connection to a data source
|
||||
func (h *DataSourceHandler) TestDataSourceConnection(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
ResponseWithError(c, http.StatusBadRequest, "Data source name is required")
|
||||
return
|
||||
}
|
||||
|
||||
var config models.DataSourceConfig
|
||||
if err := c.ShouldBindJSON(&config); err != nil {
|
||||
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Test the connection with endpoints that work
|
||||
err := testDataSourceConnection(ctx, config)
|
||||
if err != nil {
|
||||
log.Printf("Connection test failed for %s: %v", name, err)
|
||||
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Connection test failed: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Connection test successful",
|
||||
"name": name,
|
||||
})
|
||||
}
|
||||
|
||||
// testDataSourceConnection tests the connection to a data source using different endpoints
|
||||
// based on the data source type
|
||||
func testDataSourceConnection(ctx context.Context, config models.DataSourceConfig) error {
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
var url string
|
||||
switch config.Type {
|
||||
case models.PangolinAPI:
|
||||
// Use traefik-config endpoint instead of status to test Pangolin
|
||||
url = config.URL + "/traefik-config"
|
||||
case models.TraefikAPI:
|
||||
// Use http/routers endpoint to test Traefik
|
||||
url = config.URL + "/api/http/routers"
|
||||
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 failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("API returned status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/gin-contrib/static"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/hhftechnology/middleware-manager/api/handlers"
|
||||
"github.com/hhftechnology/middleware-manager/services"
|
||||
)
|
||||
|
||||
// Server represents the API server
|
||||
@@ -24,6 +25,8 @@ type Server struct {
|
||||
middlewareHandler *handlers.MiddlewareHandler
|
||||
resourceHandler *handlers.ResourceHandler
|
||||
configHandler *handlers.ConfigHandler
|
||||
dataSourceHandler *handlers.DataSourceHandler
|
||||
configManager *services.ConfigManager
|
||||
}
|
||||
|
||||
// ServerConfig contains configuration options for the server
|
||||
@@ -36,7 +39,7 @@ type ServerConfig struct {
|
||||
}
|
||||
|
||||
// NewServer creates a new API server
|
||||
func NewServer(db *sql.DB, config ServerConfig) *Server {
|
||||
func NewServer(db *sql.DB, config ServerConfig, configManager *services.ConfigManager) *Server {
|
||||
// Set gin mode based on debug flag
|
||||
if !config.Debug {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -77,6 +80,7 @@ func NewServer(db *sql.DB, config ServerConfig) *Server {
|
||||
middlewareHandler := handlers.NewMiddlewareHandler(db)
|
||||
resourceHandler := handlers.NewResourceHandler(db)
|
||||
configHandler := handlers.NewConfigHandler(db)
|
||||
dataSourceHandler := handlers.NewDataSourceHandler(configManager)
|
||||
|
||||
// Setup server
|
||||
server := &Server{
|
||||
@@ -85,6 +89,8 @@ func NewServer(db *sql.DB, config ServerConfig) *Server {
|
||||
middlewareHandler: middlewareHandler,
|
||||
resourceHandler: resourceHandler,
|
||||
configHandler: configHandler,
|
||||
dataSourceHandler: dataSourceHandler,
|
||||
configManager: configManager,
|
||||
srv: &http.Server{
|
||||
Addr: ":" + config.Port,
|
||||
Handler: router,
|
||||
@@ -132,20 +138,22 @@ func (s *Server) setupRoutes(uiPath string) {
|
||||
resources.DELETE("/:id/middlewares/:middlewareId", s.resourceHandler.RemoveMiddleware)
|
||||
|
||||
// Router configuration routes
|
||||
resources.PUT("/:id/config/http", s.configHandler.UpdateHTTPConfig) // HTTP entrypoints
|
||||
resources.PUT("/:id/config/tls", s.configHandler.UpdateTLSConfig) // TLS certificate domains
|
||||
resources.PUT("/:id/config/tcp", s.configHandler.UpdateTCPConfig) // TCP SNI routing
|
||||
resources.PUT("/:id/config/headers", s.configHandler.UpdateHeadersConfig) // Custom Host headers
|
||||
resources.PUT("/:id/config/priority", s.configHandler.UpdateRouterPriority) // Router priority
|
||||
resources.PUT("/:id/config/http", s.configHandler.UpdateHTTPConfig)
|
||||
resources.PUT("/:id/config/tls", s.configHandler.UpdateTLSConfig)
|
||||
resources.PUT("/:id/config/tcp", s.configHandler.UpdateTCPConfig)
|
||||
resources.PUT("/:id/config/headers", s.configHandler.UpdateHeadersConfig)
|
||||
resources.PUT("/:id/config/priority", s.configHandler.UpdateRouterPriority)
|
||||
}
|
||||
|
||||
// Data source routes
|
||||
datasource := api.Group("/datasource")
|
||||
{
|
||||
datasource.GET("", s.dataSourceHandler.GetDataSources)
|
||||
datasource.GET("/active", s.dataSourceHandler.GetActiveDataSource)
|
||||
datasource.PUT("/active", s.dataSourceHandler.SetActiveDataSource)
|
||||
datasource.PUT("/:name", s.dataSourceHandler.UpdateDataSource)
|
||||
datasource.POST("/:name/test", s.dataSourceHandler.TestDataSourceConnection)
|
||||
}
|
||||
// Data source routes
|
||||
datasource := api.Group("/datasource")
|
||||
{
|
||||
datasource.GET("", s.dataSourceHandler.GetDataSources)
|
||||
datasource.GET("/active", s.dataSourceHandler.GetActiveDataSource)
|
||||
datasource.PUT("/active", s.dataSourceHandler.SetActiveDataSource)
|
||||
datasource.PUT("/:name", s.dataSourceHandler.UpdateDataSource)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve the React app
|
||||
|
||||
@@ -158,6 +158,29 @@ func runPostMigrationUpdates(db *sql.DB) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if entrypoints column exists: %w", err)
|
||||
}
|
||||
|
||||
// Check for source_type column
|
||||
var hasSourceTypeColumn bool
|
||||
err = db.QueryRow(`
|
||||
SELECT COUNT(*) > 0
|
||||
FROM pragma_table_info('resources')
|
||||
WHERE name = 'source_type'
|
||||
`).Scan(&hasSourceTypeColumn)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if source_type column exists: %w", err)
|
||||
}
|
||||
|
||||
// If the column doesn't exist, add it
|
||||
if !hasSourceTypeColumn {
|
||||
log.Println("Adding source_type column to resources table")
|
||||
|
||||
if _, err := db.Exec("ALTER TABLE resources ADD COLUMN source_type TEXT DEFAULT ''"); err != nil {
|
||||
return fmt.Errorf("failed to add source_type column: %w", err)
|
||||
}
|
||||
|
||||
log.Println("Successfully added source_type column")
|
||||
}
|
||||
|
||||
// If the column doesn't exist, add the routing columns too
|
||||
if !hasEntrypointsColumn {
|
||||
|
||||
@@ -35,6 +35,9 @@ CREATE TABLE IF NOT EXISTS resources (
|
||||
-- Router priority configuration
|
||||
router_priority INTEGER DEFAULT 100,
|
||||
|
||||
-- Source type for tracking data origin
|
||||
source_type TEXT DEFAULT '',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -95,4 +95,5 @@ func GetLastInsertID(result sql.Result) (int64, error) {
|
||||
return 0, fmt.Errorf("failed to get last insert ID: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
main.go
47
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
// Configuration represents the application configuration
|
||||
type Configuration struct {
|
||||
PangolinAPIURL string
|
||||
TraefikAPIURL string
|
||||
TraefikConfDir string
|
||||
DBPath string
|
||||
Port string
|
||||
@@ -30,6 +32,37 @@ type Configuration struct {
|
||||
Debug bool
|
||||
AllowCORS bool
|
||||
CORSOrigin string
|
||||
ActiveDataSource string
|
||||
}
|
||||
|
||||
// DiscoverTraefikAPI attempts to discover the Traefik API by trying common URLs
|
||||
func DiscoverTraefikAPI() (string, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 2 * time.Second, // Short timeout for discovery
|
||||
}
|
||||
|
||||
// Common URLs to try
|
||||
urls := []string{
|
||||
"http://host.docker.internal:8080",
|
||||
"http://localhost:8080",
|
||||
"http://127.0.0.1:8080",
|
||||
"http://traefik:8080",
|
||||
}
|
||||
|
||||
for _, url := range urls {
|
||||
testURL := url + "/api/version"
|
||||
resp, err := client.Get(testURL)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
resp.Body.Close()
|
||||
log.Printf("Discovered Traefik API at %s", url)
|
||||
return url, nil
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil // Return empty string without error to allow fallbacks
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -43,6 +76,14 @@ func main() {
|
||||
// Load configuration
|
||||
cfg := loadConfiguration(debug)
|
||||
|
||||
// Try to discover Traefik API URL if not set in environment
|
||||
if os.Getenv("TRAEFIK_API_URL") == "" {
|
||||
if discoveredURL, err := DiscoverTraefikAPI(); err == nil && discoveredURL != "" {
|
||||
log.Printf("Auto-discovered Traefik API URL: %s", discoveredURL)
|
||||
cfg.TraefikAPIURL = discoveredURL
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
db, err := database.InitDB(cfg.DBPath)
|
||||
if err != nil {
|
||||
@@ -72,6 +113,9 @@ func main() {
|
||||
log.Fatalf("Failed to initialize config manager: %v", err)
|
||||
}
|
||||
|
||||
// Ensure default data sources are configured with potentially discovered URL
|
||||
configManager.EnsureDefaultDataSources(cfg.PangolinAPIURL, cfg.TraefikAPIURL)
|
||||
|
||||
// Create stop channel for graceful shutdown
|
||||
stopChan := make(chan struct{})
|
||||
|
||||
@@ -153,11 +197,14 @@ func loadConfiguration(debug bool) Configuration {
|
||||
|
||||
return Configuration{
|
||||
PangolinAPIURL: getEnv("PANGOLIN_API_URL", "http://pangolin:3001/api/v1"),
|
||||
// Changed to use host.docker.internal as first default to better support Docker environments
|
||||
TraefikAPIURL: getEnv("TRAEFIK_API_URL", "http://host.docker.internal:8080"),
|
||||
TraefikConfDir: getEnv("TRAEFIK_CONF_DIR", "/conf"),
|
||||
DBPath: getEnv("DB_PATH", "/data/middleware.db"),
|
||||
Port: getEnv("PORT", "3456"),
|
||||
UIPath: getEnv("UI_PATH", "/app/ui/build"),
|
||||
ConfigDir: getEnv("CONFIG_DIR", "/app/config"),
|
||||
ActiveDataSource: getEnv("ACTIVE_DATA_SOURCE", "pangolin"),
|
||||
CheckInterval: checkInterval,
|
||||
GenerateInterval: generateInterval,
|
||||
Debug: debug,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// DataSourceType represents the type of data source
|
||||
type DataSourceType string
|
||||
@@ -45,7 +49,7 @@ type TraefikTLSConfig struct {
|
||||
Domains []TraefikTLSDomain `json:"domains"`
|
||||
}
|
||||
|
||||
// TraefikTLSDomain represents a domain in Traefik TLS config
|
||||
// TraefikTLSDomain represents a domain in Traefik TLS configuration
|
||||
type TraefikTLSDomain struct {
|
||||
Main string `json:"main"`
|
||||
Sans []string `json:"sans"`
|
||||
@@ -56,23 +60,49 @@ type ResourceCollection struct {
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
// FormatBasicAuth formats the basic auth field to mask the password
|
||||
func (dc *DataSourceConfig) FormatBasicAuth() {
|
||||
// If the password is not empty, mask it for display
|
||||
if dc.BasicAuth.Password != "" {
|
||||
dc.BasicAuth.Password = "••••••••" // Mask the password
|
||||
}
|
||||
}
|
||||
|
||||
// JoinTLSDomains extracts TLS domains into a comma-separated string
|
||||
func JoinTLSDomains(domains []TraefikTLSDomain) string {
|
||||
var result []string
|
||||
for _, domain := range domains {
|
||||
// Add the main domain if not empty
|
||||
if domain.Main != "" {
|
||||
result = append(result, domain.Main)
|
||||
}
|
||||
|
||||
// Add all the SANs (Subject Alternative Names) from the domain
|
||||
if len(domain.Sans) > 0 {
|
||||
result = append(result, domain.Sans...)
|
||||
}
|
||||
}
|
||||
return strings.Join(result, ",")
|
||||
}
|
||||
|
||||
|
||||
// Resource extends the existing resource model with source type
|
||||
type Resource struct {
|
||||
ID string `json:"id"`
|
||||
Host string `json:"host"`
|
||||
ServiceID string `json:"service_id"`
|
||||
OrgID string `json:"org_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
Status string `json:"status"`
|
||||
SourceType string `json:"source_type"`
|
||||
Entrypoints string `json:"entrypoints"`
|
||||
TLSDomains string `json:"tls_domains"`
|
||||
TCPEnabled bool `json:"tcp_enabled"`
|
||||
TCPEntrypoints string `json:"tcp_entrypoints"`
|
||||
TCPSNIRule string `json:"tcp_sni_rule"`
|
||||
CustomHeaders string `json:"custom_headers"`
|
||||
RouterPriority int `json:"router_priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Middlewares string `json:"middlewares,omitempty"`
|
||||
}
|
||||
// type Resource struct {
|
||||
// ID string `json:"id"`
|
||||
// Host string `json:"host"`
|
||||
// ServiceID string `json:"service_id"`
|
||||
// OrgID string `json:"org_id"`
|
||||
// SiteID string `json:"site_id"`
|
||||
// Status string `json:"status"`
|
||||
// SourceType string `json:"source_type"`
|
||||
// Entrypoints string `json:"entrypoints"`
|
||||
// TLSDomains string `json:"tls_domains"`
|
||||
// TCPEnabled bool `json:"tcp_enabled"`
|
||||
// TCPEntrypoints string `json:"tcp_entrypoints"`
|
||||
// TCPSNIRule string `json:"tcp_sni_rule"`
|
||||
// CustomHeaders string `json:"custom_headers"`
|
||||
// RouterPriority int `json:"router_priority"`
|
||||
// CreatedAt time.Time `json:"created_at"`
|
||||
// UpdatedAt time.Time `json:"updated_at"`
|
||||
// Middlewares string `json:"middlewares,omitempty"`
|
||||
// }
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
@@ -30,4 +31,6 @@ type ResourceMiddleware struct {
|
||||
MiddlewareID string `json:"middleware_id"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
}
|
||||
// Resource struct removed to resolve redeclaration error.
|
||||
// Please ensure the Resource struct is only defined in one file (likely resource.go).
|
||||
@@ -27,11 +27,14 @@ type Resource struct {
|
||||
// Custom headers configuration
|
||||
CustomHeaders string `json:"custom_headers"`
|
||||
|
||||
// Router priority configuration
|
||||
RouterPriority int `json:"router_priority"`
|
||||
|
||||
// Source type for tracking data origin
|
||||
SourceType string `json:"source_type"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Middlewares is a list of associated middlewares, populated when needed
|
||||
Middlewares []ResourceMiddleware `json:"middlewares,omitempty"`
|
||||
}
|
||||
|
||||
// PangolinResource represents the format of a resource from Pangolin API
|
||||
|
||||
@@ -349,10 +349,19 @@ func preserveStringsInYamlNode(node *yaml.Node) {
|
||||
// For scalar nodes (including strings), ensure empty strings are properly quoted
|
||||
if node.Value == "" {
|
||||
node.Style = yaml.DoubleQuotedStyle
|
||||
} else if len(node.Value) > 5 && isNumeric(node.Value) {
|
||||
// Force large numbers to be strings to avoid scientific notation
|
||||
node.Tag = "!!str"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
// isNumeric checks if a string is numeric
|
||||
func isNumeric(s string) bool {
|
||||
_, err := strconv.ParseInt(s, 10, 64)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// containsSpecialField checks if a field name is one that needs special handling
|
||||
// for correct string value preservation
|
||||
func containsSpecialField(fieldName string) bool {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhftechnology/middleware-manager/models"
|
||||
)
|
||||
@@ -48,7 +53,7 @@ func (cm *ConfigManager) loadConfig() error {
|
||||
},
|
||||
"traefik": {
|
||||
Type: models.TraefikAPI,
|
||||
URL: "http://traefik:8080",
|
||||
URL: "http://host.docker.internal:8080",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -71,6 +76,66 @@ func (cm *ConfigManager) loadConfig() error {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 if traefikURL != "" {
|
||||
// Update Traefik URL if provided (could be auto-discovered)
|
||||
tConfig := cm.config.DataSources["traefik"]
|
||||
if 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
|
||||
@@ -124,7 +189,20 @@ func (cm *ConfigManager) SetActiveDataSource(name string) error {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -147,6 +225,77 @@ func (cm *ConfigManager) UpdateDataSource(name string, config models.DataSourceC
|
||||
cm.mu.Lock()
|
||||
defer cm.mu.Unlock()
|
||||
|
||||
cm.config.DataSources[name] = config
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (f *PangolinFetcher) FetchResources(ctx context.Context) (*models.ResourceC
|
||||
}
|
||||
|
||||
// Skip system routers
|
||||
if isSystemRouter(id) {
|
||||
if isPangolinSystemRouter(id) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -105,8 +105,8 @@ func (f *PangolinFetcher) FetchResources(ctx context.Context) (*models.ResourceC
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// isSystemRouter checks if a router is a system router (to be skipped)
|
||||
func isSystemRouter(routerID string) bool {
|
||||
// isPangolinSystemRouter checks if a router is a Pangolin system router (to be skipped)
|
||||
func isPangolinSystemRouter(routerID string) bool {
|
||||
systemPrefixes := []string{
|
||||
"api-router",
|
||||
"next-router",
|
||||
@@ -120,4 +120,10 @@ func isSystemRouter(routerID string) bool {
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Helper function to extract TLS domains into a comma-separated string
|
||||
// Note: This function is updated to use the model package's function
|
||||
func extractTLSDomains(domains []models.TraefikTLSDomain) string {
|
||||
return models.JoinTLSDomains(domains)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func NewResourceFetcher(config models.DataSourceConfig) (ResourceFetcher, error)
|
||||
|
||||
// Helper function to extract host from a Traefik rule
|
||||
func extractHostFromRule(rule string) string {
|
||||
// Simple regex-free parser for Host(`example.com`) pattern
|
||||
// Handle Host pattern - the original implementation
|
||||
hostStart := "Host(`"
|
||||
if start := strings.Index(rule, hostStart); start != -1 {
|
||||
start += len(hostStart)
|
||||
@@ -35,6 +35,137 @@ func extractHostFromRule(rule string) string {
|
||||
return rule[start : start+end]
|
||||
}
|
||||
}
|
||||
|
||||
// Handle HostRegexp pattern
|
||||
hostRegexpStart := "HostRegexp(`"
|
||||
if start := strings.Index(rule, hostRegexpStart); start != -1 {
|
||||
start += len(hostRegexpStart)
|
||||
if end := strings.Index(rule[start:], "`)"); end != -1 {
|
||||
// Extract the regexp pattern
|
||||
pattern := rule[start : start+end]
|
||||
// Handle patterns like .+ by returning a useful name
|
||||
if pattern == ".+" {
|
||||
return "any-host" // Placeholder for wildcard
|
||||
}
|
||||
// Handle more specific patterns
|
||||
return extractHostFromRegexp(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle legacy Host:example.com pattern (no backticks)
|
||||
legacyHostStart := "Host:"
|
||||
if start := strings.Index(rule, legacyHostStart); start != -1 {
|
||||
start += len(legacyHostStart)
|
||||
// Extract until space, comma, or end of string
|
||||
end := len(rule)
|
||||
for i, c := range rule[start:] {
|
||||
if c == ' ' || c == ',' || c == ')' {
|
||||
end = start + i
|
||||
break
|
||||
}
|
||||
}
|
||||
if start < end {
|
||||
return rule[start:end]
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from complex rules with && operators
|
||||
if strings.Contains(rule, "&&") {
|
||||
parts := strings.Split(rule, "&&")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if host := extractHostFromRule(part); host != "" {
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper function to extract hostname from regex patterns
|
||||
func extractHostFromRegexp(pattern string) string {
|
||||
// Handle common pattern formats for subdomains
|
||||
if strings.Contains(pattern, ".development.hhf.technology") {
|
||||
// Extract subdomain part if possible
|
||||
parts := strings.Split(pattern, ".development.hhf.technology")
|
||||
// Clean up any regex special chars from subdomain
|
||||
subdomain := cleanupRegexChars(parts[0])
|
||||
return subdomain + ".development.hhf.technology"
|
||||
}
|
||||
|
||||
// Handle other domain patterns
|
||||
if strings.Contains(pattern, ".") {
|
||||
// Attempt to extract a domain-like pattern
|
||||
return cleanupRegexChars(pattern)
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return cleanupRegexChars(pattern)
|
||||
}
|
||||
|
||||
// Helper function to clean up regex characters for readability
|
||||
func cleanupRegexChars(s string) string {
|
||||
// Replace common regex patterns with simpler representations
|
||||
replacements := []struct {
|
||||
from string
|
||||
to string
|
||||
}{
|
||||
{`\d+`, "N"}, // digit sequences
|
||||
{`[0-9]+`, "N"}, // digit class sequences
|
||||
{`[a-z0-9]+`, "x"}, // alphanumeric lowercase class
|
||||
{`[a-zA-Z0-9]+`, "x"}, // alphanumeric class
|
||||
{`[a-z]+`, "x"}, // alpha lowercase class
|
||||
{`[A-Z]+`, "X"}, // alpha uppercase class
|
||||
{`[a-zA-Z]+`, "X"}, // alpha class
|
||||
{`\w+`, "x"}, // word char sequences
|
||||
{`[^/]+`, "x"}, // non-slash sequences
|
||||
{`.*`, "x"}, // any char sequences
|
||||
{`.+`, "x"}, // one or more any char
|
||||
{`^`, ""}, // start anchor
|
||||
{`$`, ""}, // end anchor
|
||||
{`\`, ""}, // escapes
|
||||
{`(`, ""}, // groups
|
||||
{`)`, ""},
|
||||
{`{`, ""}, // repetition
|
||||
{`}`, ""},
|
||||
{`[`, ""}, // character classes
|
||||
{`]`, ""},
|
||||
{`?`, ""}, // optional
|
||||
{`*`, ""}, // zero or more
|
||||
{`+`, ""}, // one or more
|
||||
{`|`, "-"}, // alternation
|
||||
}
|
||||
|
||||
result := s
|
||||
for _, r := range replacements {
|
||||
result = strings.Replace(result, r.from, r.to, -1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// Helper function to extract hostname from HostSNI rule
|
||||
func extractHostSNI(rule string) string {
|
||||
hostStart := "HostSNI(`"
|
||||
if start := strings.Index(rule, hostStart); start != -1 {
|
||||
start += len(hostStart)
|
||||
if end := strings.Index(rule[start:], "`)"); end != -1 {
|
||||
return rule[start : start+end]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper function to extract hostname pattern from HostSNIRegexp rule
|
||||
func extractHostSNIRegexp(rule string) string {
|
||||
hostStart := "HostSNIRegexp(`"
|
||||
if start := strings.Index(rule, hostStart); start != -1 {
|
||||
start += len(hostStart)
|
||||
if end := strings.Index(rule[start:], "`)"); end != -1 {
|
||||
return extractHostFromRegexp(rule[start : start+end])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -45,12 +176,6 @@ func joinEntrypoints(entrypoints []string) string {
|
||||
|
||||
// Helper function to extract TLS domains into a comma-separated string
|
||||
func joinTLSDomains(domains []models.TraefikTLSDomain) string {
|
||||
var result []string
|
||||
for _, domain := range domains {
|
||||
if domain.Main != "" {
|
||||
result = append(result, domain.Main)
|
||||
}
|
||||
result = append(result, domain.Sans...)
|
||||
}
|
||||
return strings.Join(result, ",")
|
||||
// Call the function from the models package
|
||||
return models.JoinTLSDomains(domains)
|
||||
}
|
||||
@@ -343,8 +343,9 @@ func (rw *ResourceWatcher) fetchTraefikConfig(ctx context.Context) (*models.Pang
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// isSystemRouter checks if a router is a system router (to be skipped)
|
||||
func isSystemRouter(routerID string) bool {
|
||||
// isSystemRouterForResourceWatcher checks if a router is a system router (to be skipped)
|
||||
// This is renamed to prevent collision with the function in pangolin_fetcher.go
|
||||
func isSystemRouterForResourceWatcher(routerID string) bool {
|
||||
systemPrefixes := []string{
|
||||
"api-router",
|
||||
"next-router",
|
||||
|
||||
@@ -29,10 +29,59 @@ func NewTraefikFetcher(config models.DataSourceConfig) *TraefikFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
// FetchResources fetches resources from Traefik API
|
||||
// FetchResources fetches resources from Traefik API with fallback options
|
||||
func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCollection, error) {
|
||||
// Create HTTP request with auth if needed
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.config.URL+"/api/http/routers", nil)
|
||||
log.Println("Fetching resources from Traefik API...")
|
||||
|
||||
// Try the configured URL first
|
||||
resources, err := f.fetchResourcesFromURL(ctx, f.config.URL)
|
||||
if err == nil {
|
||||
log.Printf("Successfully fetched resources from %s", f.config.URL)
|
||||
return resources, 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: %s", url)
|
||||
resources, err := f.fetchResourcesFromURL(ctx, url)
|
||||
if err == nil {
|
||||
// Success with fallback - remember this URL for next time
|
||||
f.suggestURLUpdate(url)
|
||||
return resources, 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)
|
||||
}
|
||||
|
||||
// fetchResourcesFromURL fetches resources from a specific URL
|
||||
func (f *TraefikFetcher) fetchResourcesFromURL(ctx context.Context, baseURL string) (*models.ResourceCollection, error) {
|
||||
// Create HTTP request to fetch HTTP routers
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/http/routers", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
@@ -54,7 +103,7 @@ func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCo
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Process response
|
||||
// Read and parse response body
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
@@ -63,12 +112,29 @@ func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCo
|
||||
// Parse the Traefik routers response
|
||||
var traefikRouters []models.TraefikRouter
|
||||
if err := json.Unmarshal(body, &traefikRouters); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
// Try parsing as a map if array unmarshaling fails (Traefik API might return different formats)
|
||||
var routersMap map[string]models.TraefikRouter
|
||||
if jsonErr := json.Unmarshal(body, &routersMap); jsonErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse routers JSON: %w", err)
|
||||
}
|
||||
|
||||
// Convert map to array
|
||||
for name, router := range routersMap {
|
||||
router.Name = name // Set the name from the map key
|
||||
traefikRouters = append(traefikRouters, router)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert Traefik routers to our internal model
|
||||
resources := &models.ResourceCollection{
|
||||
Resources: make([]models.Resource, 0, len(traefikRouters)),
|
||||
Resources: make([]models.Resource, 0),
|
||||
}
|
||||
|
||||
// Get TLS domains for routers by making a separate request to the Traefik API
|
||||
tlsDomainsMap, err := f.fetchTLSDomains(ctx, baseURL)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to fetch TLS domains: %v", err)
|
||||
// Continue without TLS domains, as this is not critical
|
||||
}
|
||||
|
||||
for _, router := range traefikRouters {
|
||||
@@ -77,17 +143,24 @@ func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCo
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip routers without TLS (typically HTTP redirects)
|
||||
if router.TLS.CertResolver == "" {
|
||||
// Skip routers without TLS only if configured to do so
|
||||
if router.TLS.CertResolver == "" && !shouldIncludeNonTLSRouters() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip system routers (dashboard, api, etc.)
|
||||
if isTraefikSystemRouter(router.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract host from rule
|
||||
host := extractHostFromRule(router.Rule)
|
||||
if host == "" {
|
||||
log.Printf("Could not extract host from rule: %s", router.Rule)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create resource
|
||||
resource := models.Resource{
|
||||
ID: router.Name,
|
||||
Host: host,
|
||||
@@ -98,9 +171,12 @@ func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCo
|
||||
RouterPriority: router.Priority,
|
||||
}
|
||||
|
||||
// Extract TLS domains if present
|
||||
if router.TLS.Domains != nil && len(router.TLS.Domains) > 0 {
|
||||
resource.TLSDomains = joinTLSDomains(router.TLS.Domains)
|
||||
// Add TLS domains if available
|
||||
if tlsDomains, exists := tlsDomainsMap[router.Name]; exists {
|
||||
resource.TLSDomains = tlsDomains
|
||||
} else if len(router.TLS.Domains) > 0 {
|
||||
// Use domains from the router if available
|
||||
resource.TLSDomains = models.JoinTLSDomains(router.TLS.Domains)
|
||||
}
|
||||
|
||||
resources.Resources = append(resources.Resources, resource)
|
||||
@@ -108,4 +184,160 @@ func (f *TraefikFetcher) FetchResources(ctx context.Context) (*models.ResourceCo
|
||||
|
||||
log.Printf("Fetched %d resources from Traefik API", len(resources.Resources))
|
||||
return resources, nil
|
||||
}
|
||||
|
||||
// suggestURLUpdate logs a message suggesting the URL should be updated
|
||||
func (f *TraefikFetcher) suggestURLUpdate(workingURL string) {
|
||||
log.Printf("IMPORTANT: Consider updating the Traefik API URL to %s in the settings", workingURL)
|
||||
}
|
||||
|
||||
// fetchTLSDomains fetches TLS configuration for routers from Traefik API
|
||||
func (f *TraefikFetcher) fetchTLSDomains(ctx context.Context, baseURL string) (map[string]string, error) {
|
||||
// Create HTTP request to fetch TLS configuration
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/http/routers", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create TLS domains 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("TLS domains HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TLS domains unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read and parse response body
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read TLS domains response: %w", err)
|
||||
}
|
||||
|
||||
// First try to parse as a map of routers
|
||||
var routersMap map[string]models.TraefikRouter
|
||||
if err := json.Unmarshal(body, &routersMap); err != nil {
|
||||
// If map parsing fails, try to parse as an array
|
||||
var routersArray []models.TraefikRouter
|
||||
if jsonErr := json.Unmarshal(body, &routersArray); jsonErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse TLS domains JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract TLS domains for each router from the array
|
||||
domainsMap := make(map[string]string)
|
||||
for _, router := range routersArray {
|
||||
if len(router.TLS.Domains) > 0 && router.Name != "" {
|
||||
domainsMap[router.Name] = models.JoinTLSDomains(router.TLS.Domains)
|
||||
}
|
||||
}
|
||||
return domainsMap, nil
|
||||
}
|
||||
|
||||
// Extract TLS domains for each router from the map
|
||||
domainsMap := make(map[string]string)
|
||||
for name, router := range routersMap {
|
||||
if len(router.TLS.Domains) > 0 {
|
||||
domainsMap[name] = models.JoinTLSDomains(router.TLS.Domains)
|
||||
}
|
||||
}
|
||||
|
||||
return domainsMap, nil
|
||||
}
|
||||
|
||||
// fetchTCPRouters fetches TCP routers from Traefik API
|
||||
func (f *TraefikFetcher) fetchTCPRouters(ctx context.Context, baseURL string) ([]models.TraefikRouter, error) {
|
||||
// Create HTTP request to fetch TCP routers
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/api/tcp/routers", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create TCP routers 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("TCP routers HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status code
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TCP routers unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Read and parse response body
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read TCP routers response: %w", err)
|
||||
}
|
||||
|
||||
// Try to parse as an array of routers
|
||||
var tcpRouters []models.TraefikRouter
|
||||
if err := json.Unmarshal(body, &tcpRouters); err != nil {
|
||||
// Try parsing as a map if array unmarshaling fails
|
||||
var routersMap map[string]models.TraefikRouter
|
||||
if jsonErr := json.Unmarshal(body, &routersMap); jsonErr != nil {
|
||||
return nil, fmt.Errorf("failed to parse TCP routers JSON: %w", err)
|
||||
}
|
||||
|
||||
// Convert map to array
|
||||
for name, router := range routersMap {
|
||||
router.Name = name // Set the name from the map key
|
||||
tcpRouters = append(tcpRouters, router)
|
||||
}
|
||||
}
|
||||
|
||||
return tcpRouters, nil
|
||||
}
|
||||
|
||||
// shouldIncludeNonTLSRouters returns whether non-TLS routers should be included
|
||||
// This could be made configurable through system settings
|
||||
func shouldIncludeNonTLSRouters() bool {
|
||||
return true // Changed to true to include non-TLS routers
|
||||
}
|
||||
|
||||
// isTraefikSystemRouter checks if a router is a Traefik system router (to be skipped)
|
||||
func isTraefikSystemRouter(routerID string) bool {
|
||||
// Keep original system prefixes but improve matching
|
||||
systemPrefixes := []string{
|
||||
"api@internal",
|
||||
"dashboard@internal",
|
||||
"acme-http@internal",
|
||||
}
|
||||
|
||||
// But allow these specific patterns that are user routers and not internal system routers
|
||||
userPatterns := []string{
|
||||
"-router",
|
||||
"api-router@file",
|
||||
"next-router@file",
|
||||
"ws-router@file",
|
||||
}
|
||||
|
||||
// First check if it matches any user patterns - if so, don't skip it
|
||||
for _, pattern := range userPatterns {
|
||||
if strings.Contains(routerID, pattern) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Then check if it matches any system prefixes
|
||||
for _, prefix := range systemPrefixes {
|
||||
if strings.Contains(routerID, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -2,24 +2,48 @@ import React from 'react';
|
||||
import { AppProvider, useApp } from './contexts/AppContext';
|
||||
import { ResourceProvider } from './contexts/ResourceContext';
|
||||
import { MiddlewareProvider } from './contexts/MiddlewareContext';
|
||||
import { DataSourceProvider } from './contexts/DataSourceContext';
|
||||
import { Header } from './components/common';
|
||||
|
||||
// Import page components (these would be created as separate files)
|
||||
// Import page components
|
||||
import Dashboard from './components/dashboard/Dashboard';
|
||||
import ResourcesList from './components/resources/ResourcesList';
|
||||
import ResourceDetail from './components/resources/ResourceDetail';
|
||||
import MiddlewaresList from './components/middlewares/MiddlewaresList';
|
||||
import MiddlewareForm from './components/middlewares/MiddlewareForm';
|
||||
import DataSourceSettings from './components/settings/DataSourceSettings';
|
||||
|
||||
/**
|
||||
* Main application component that renders the current page
|
||||
* based on the navigation state
|
||||
*/
|
||||
const MainContent = () => {
|
||||
const { page, resourceId, middlewareId, isEditing, navigateTo, isDarkMode, setIsDarkMode } = useApp();
|
||||
const {
|
||||
page,
|
||||
resourceId,
|
||||
middlewareId,
|
||||
isEditing,
|
||||
navigateTo,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
showSettings,
|
||||
setShowSettings
|
||||
} = useApp();
|
||||
|
||||
// Render the active page based on state
|
||||
const renderPage = () => {
|
||||
// If settings panel should be displayed, show it as an overlay
|
||||
if (showSettings) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="w-full max-w-3xl max-h-screen">
|
||||
<DataSourceSettings onClose={() => setShowSettings(false)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the current page
|
||||
switch (page) {
|
||||
case 'dashboard':
|
||||
return <Dashboard navigateTo={navigateTo} />;
|
||||
@@ -49,6 +73,7 @@ const MainContent = () => {
|
||||
navigateTo={navigateTo}
|
||||
isDarkMode={isDarkMode}
|
||||
setIsDarkMode={setIsDarkMode}
|
||||
openSettings={() => setShowSettings(true)}
|
||||
/>
|
||||
<main className="container mx-auto px-6 py-6">
|
||||
{renderPage()}
|
||||
@@ -63,11 +88,13 @@ const MainContent = () => {
|
||||
const App = () => {
|
||||
return (
|
||||
<AppProvider>
|
||||
<ResourceProvider>
|
||||
<MiddlewareProvider>
|
||||
<MainContent />
|
||||
</MiddlewareProvider>
|
||||
</ResourceProvider>
|
||||
<DataSourceProvider>
|
||||
<ResourceProvider>
|
||||
<MiddlewareProvider>
|
||||
<MainContent />
|
||||
</MiddlewareProvider>
|
||||
</ResourceProvider>
|
||||
</DataSourceProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,51 +9,162 @@ import DarkModeToggle from './DarkModeToggle';
|
||||
* @param {function} props.navigateTo - Function to navigate to a different page
|
||||
* @param {boolean} props.isDarkMode - Current dark mode state
|
||||
* @param {function} props.setIsDarkMode - Function to toggle dark mode
|
||||
* @param {function} props.openSettings - Function to open data source settings
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const Header = ({ currentPage, navigateTo, isDarkMode, setIsDarkMode }) => {
|
||||
const Header = ({
|
||||
currentPage,
|
||||
navigateTo,
|
||||
isDarkMode,
|
||||
setIsDarkMode,
|
||||
openSettings
|
||||
}) => {
|
||||
return (
|
||||
<nav className="bg-white shadow-sm">
|
||||
<div className="container mx-auto px-6 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xl font-semibold text-gray-700">
|
||||
Pangolin Middleware Manager
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<span className="text-xl font-medium text-gray-800">
|
||||
Pangolin Middleware Manager
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="space-x-4">
|
||||
<div className="hidden md:ml-6 md:flex md:space-x-6">
|
||||
<button
|
||||
onClick={() => navigateTo('dashboard')}
|
||||
className={`px-3 py-2 rounded hover:bg-gray-100 ${
|
||||
currentPage === 'dashboard' ? 'bg-gray-100' : ''
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === 'dashboard'
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigateTo('resources')}
|
||||
className={`px-3 py-2 rounded hover:bg-gray-100 ${
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === 'resources' || currentPage === 'resource-detail'
|
||||
? 'bg-gray-100'
|
||||
: ''
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Resources
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigateTo('middlewares')}
|
||||
className={`px-3 py-2 rounded hover:bg-gray-100 ${
|
||||
className={`px-3 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === 'middlewares' || currentPage === 'middleware-form'
|
||||
? 'bg-gray-100'
|
||||
: ''
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Middlewares
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="group flex items-center px-3 py-2 text-sm font-medium rounded-md text-gray-700 hover:bg-gray-50 hover:text-gray-900"
|
||||
aria-label="Settings"
|
||||
title="Data Source Settings"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-1"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center">
|
||||
<DarkModeToggle isDark={isDarkMode} setIsDark={setIsDarkMode} />
|
||||
</div>
|
||||
<DarkModeToggle isDark={isDarkMode} setIsDark={setIsDarkMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu, show/hide based on menu state */}
|
||||
<div className="md:hidden">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
|
||||
<button
|
||||
onClick={() => navigateTo('dashboard')}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
currentPage === 'dashboard'
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigateTo('resources')}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
currentPage === 'resources' || currentPage === 'resource-detail'
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Resources
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigateTo('middlewares')}
|
||||
className={`block px-3 py-2 rounded-md text-base font-medium ${
|
||||
currentPage === 'middlewares' || currentPage === 'middleware-form'
|
||||
? 'bg-gray-100 text-gray-900'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
Middlewares
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={openSettings}
|
||||
className="flex items-center px-3 py-2 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
|
||||
});
|
||||
const [configText, setConfigText] = useState('{\n "users": [\n "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"\n ]\n}');
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [orderedMiddlewares, setOrderedMiddlewares] = useState([]);
|
||||
|
||||
// Available middleware types
|
||||
const middlewareTypes = [
|
||||
@@ -74,6 +75,13 @@ const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
|
||||
});
|
||||
|
||||
setConfigText(configJson);
|
||||
|
||||
// Extract and set ordered middlewares for chain type
|
||||
if (selectedMiddleware.type === 'chain' &&
|
||||
selectedMiddleware.config &&
|
||||
selectedMiddleware.config.middlewares) {
|
||||
setOrderedMiddlewares(selectedMiddleware.config.middlewares);
|
||||
}
|
||||
}
|
||||
}, [isEditing, selectedMiddleware]);
|
||||
|
||||
@@ -85,17 +93,46 @@ const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
|
||||
// Get template for this middleware type
|
||||
const template = getConfigTemplate(newType);
|
||||
setConfigText(template);
|
||||
|
||||
// Reset ordered middlewares when switching to/from chain type
|
||||
if (newType === 'chain') {
|
||||
setOrderedMiddlewares([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle middleware selection for chain type
|
||||
const handleMiddlewareSelection = (e) => {
|
||||
const options = e.target.options;
|
||||
const selected = Array.from(options)
|
||||
const selectedValues = Array.from(options)
|
||||
.filter(option => option.selected)
|
||||
.map(option => option.value);
|
||||
|
||||
// Update the config text to reflect the selected middlewares
|
||||
const configObj = { middlewares: selected };
|
||||
// Keep track of previously selected middlewares
|
||||
const previouslySelected = orderedMiddlewares;
|
||||
|
||||
// Create a new ordered array that preserves existing order
|
||||
// and adds new selections at the end (or removes unselected ones)
|
||||
const newOrderedMiddlewares = [];
|
||||
|
||||
// First add all previously selected items that are still selected
|
||||
for (const id of previouslySelected) {
|
||||
if (selectedValues.includes(id)) {
|
||||
newOrderedMiddlewares.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Then add any newly selected items in their original DOM order
|
||||
for (const id of selectedValues) {
|
||||
if (!newOrderedMiddlewares.includes(id)) {
|
||||
newOrderedMiddlewares.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the ordered state
|
||||
setOrderedMiddlewares(newOrderedMiddlewares);
|
||||
|
||||
// Update the config text with the ordered array
|
||||
const configObj = { middlewares: newOrderedMiddlewares };
|
||||
setConfigText(JSON.stringify(configObj, null, 2));
|
||||
};
|
||||
|
||||
@@ -227,6 +264,7 @@ const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
|
||||
onChange={handleMiddlewareSelection}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
size={Math.min(8, middlewares.length)}
|
||||
value={orderedMiddlewares}
|
||||
>
|
||||
{middlewares
|
||||
.filter(m => m.id !== id) // Filter out current middleware if editing
|
||||
@@ -239,6 +277,23 @@ const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Hold Ctrl (or Cmd) to select multiple middlewares. Middlewares will be applied in the order selected.
|
||||
</p>
|
||||
|
||||
{orderedMiddlewares.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-gray-50 border rounded">
|
||||
<h4 className="text-sm font-bold mb-2">Current Middleware Order:</h4>
|
||||
<ol className="list-decimal pl-5">
|
||||
{orderedMiddlewares.map((mwId, index) => {
|
||||
const mw = middlewares.find(m => m.id === mwId);
|
||||
return (
|
||||
<li key={mwId} className="text-sm py-1">
|
||||
{mw ? `${mw.name} (${mw.type})` : mwId}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="text-xs text-gray-500 mt-2">Middlewares will be applied in this order.</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded text-blue-700">
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LoadingSpinner, ErrorMessage } from '../common';
|
||||
import { useDataSource, useApp } from '../../contexts';
|
||||
|
||||
/**
|
||||
* DataSourceSettings component for managing API data sources
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {function} props.onClose - Function to close settings panel
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
const DataSourceSettings = ({ onClose }) => {
|
||||
// Get data source state from context
|
||||
const {
|
||||
dataSources,
|
||||
activeSource,
|
||||
loading,
|
||||
error,
|
||||
fetchDataSources,
|
||||
setActiveDataSource,
|
||||
updateDataSource,
|
||||
setError
|
||||
} = useDataSource();
|
||||
|
||||
// Get app context to refresh the app state
|
||||
const { fetchActiveDataSource } = useApp();
|
||||
|
||||
// State for UI
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState({});
|
||||
|
||||
// Form state for editing a data source
|
||||
const [editSource, setEditSource] = useState(null);
|
||||
const [sourceForm, setSourceForm] = useState({
|
||||
type: 'pangolin',
|
||||
url: '',
|
||||
basicAuth: {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Test connection handler with improved endpoints
|
||||
const testConnection = async (name, config) => {
|
||||
try {
|
||||
setConnectionStatus(prev => ({
|
||||
...prev,
|
||||
[name]: { testing: true }
|
||||
}));
|
||||
|
||||
// Modify the config to use the correct endpoints for testing
|
||||
const testConfig = {...config};
|
||||
|
||||
// Use the endpoints we know work from the successful curl commands
|
||||
if (testConfig.type === 'pangolin') {
|
||||
// Use the working traefik-config endpoint instead of status
|
||||
testConfig.url = testConfig.url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
// Test request will be made to /api/datasource/{name}/test
|
||||
// We'll configure the backend test to use /traefik-config for testing Pangolin
|
||||
} else if (testConfig.type === 'traefik') {
|
||||
// Use the working /api/http/routers endpoint for Traefik
|
||||
testConfig.url = testConfig.url.replace(/\/+$/, ''); // Remove trailing slashes
|
||||
|
||||
// Test request will use /api/http/routers for testing Traefik
|
||||
}
|
||||
|
||||
// Make a POST request to test the connection
|
||||
const response = await fetch(`/api/datasource/${name}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(testConfig)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setConnectionStatus(prev => ({
|
||||
...prev,
|
||||
[name]: { success: true, message: 'Connection successful!' }
|
||||
}));
|
||||
} else {
|
||||
const data = await response.json();
|
||||
setConnectionStatus(prev => ({
|
||||
...prev,
|
||||
[name]: {
|
||||
error: true,
|
||||
message: `Connection failed: ${data.message || response.statusText}`
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
setConnectionStatus(prev => ({
|
||||
...prev,
|
||||
[name]: {
|
||||
error: true,
|
||||
message: `Connection test failed: ${err.message}`
|
||||
}
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Set active data source handler
|
||||
const handleSetActiveSource = async (sourceName) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const success = await setActiveDataSource(sourceName);
|
||||
|
||||
if (success) {
|
||||
// Refresh the app state
|
||||
fetchActiveDataSource();
|
||||
|
||||
// Show a success message
|
||||
alert(`Data source changed to ${sourceName}`);
|
||||
|
||||
// Test connections again to update status
|
||||
testAllConnections();
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error setting active data source:', err);
|
||||
setError(`Failed to set active data source: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update a data source handler
|
||||
const handleUpdateDataSource = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!editSource) return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const success = await updateDataSource(editSource, sourceForm);
|
||||
|
||||
if (success) {
|
||||
// Close the form
|
||||
setEditSource(null);
|
||||
|
||||
// Show a success message
|
||||
alert(`Data source ${editSource} updated successfully`);
|
||||
|
||||
// Refresh the list
|
||||
fetchDataSources();
|
||||
|
||||
// Test this connection
|
||||
testConnection(editSource, sourceForm);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error updating data source:', err);
|
||||
setError(`Failed to update data source: ${err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Edit a data source
|
||||
const handleEditSource = (name) => {
|
||||
const source = dataSources[name];
|
||||
setSourceForm({
|
||||
type: source.type || 'pangolin',
|
||||
url: source.url || '',
|
||||
basicAuth: {
|
||||
username: source.basic_auth?.username || '',
|
||||
password: '' // Don't populate password field for security
|
||||
}
|
||||
});
|
||||
setEditSource(name);
|
||||
};
|
||||
|
||||
// Handle form input changes
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name.startsWith('basicAuth.')) {
|
||||
// Handle nested basicAuth fields
|
||||
const field = name.split('.')[1];
|
||||
setSourceForm({
|
||||
...sourceForm,
|
||||
basicAuth: {
|
||||
...sourceForm.basicAuth,
|
||||
[field]: value
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Handle top-level fields
|
||||
setSourceForm({
|
||||
...sourceForm,
|
||||
[name]: value
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Cancel editing
|
||||
const handleCancelEdit = () => {
|
||||
setEditSource(null);
|
||||
setSourceForm({
|
||||
type: 'pangolin',
|
||||
url: '',
|
||||
basicAuth: {
|
||||
username: '',
|
||||
password: ''
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Render the connection status for each data source
|
||||
const renderConnectionStatus = (name) => {
|
||||
const status = connectionStatus[name];
|
||||
|
||||
if (!status) return null;
|
||||
|
||||
if (status.testing) {
|
||||
return <div className="text-sm text-gray-500">Testing connection...</div>;
|
||||
}
|
||||
|
||||
if (status.success) {
|
||||
return <div className="text-sm text-green-500">{status.message}</div>;
|
||||
}
|
||||
|
||||
if (status.error) {
|
||||
return <div className="text-sm text-red-500">{status.message}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Function to test all connections
|
||||
const testAllConnections = () => {
|
||||
Object.entries(dataSources).forEach(([name, source]) => {
|
||||
testConnection(name, source);
|
||||
});
|
||||
};
|
||||
|
||||
// Test connections on mount
|
||||
useEffect(() => {
|
||||
if (Object.keys(dataSources).length > 0) {
|
||||
testAllConnections();
|
||||
}
|
||||
}, [dataSources]);
|
||||
|
||||
// Test manual connection during editing
|
||||
const handleTestFormConnection = async () => {
|
||||
try {
|
||||
// Use the form data for the test
|
||||
const result = await fetch(`/api/datasource/${editSource}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(sourceForm)
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
alert('Connection test successful!');
|
||||
} else {
|
||||
const data = await result.json();
|
||||
alert(`Connection test failed: ${data.message || result.statusText}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Connection test failed: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && Object.keys(dataSources).length === 0) {
|
||||
return <LoadingSpinner message="Loading data source settings..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white p-4 md:p-6 rounded-lg shadow-lg overflow-y-auto max-h-[90vh]">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Data Source Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<ErrorMessage
|
||||
message={error}
|
||||
onDismiss={() => setError(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold mb-3">Active Data Source</h3>
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium mr-3">Current:</span>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-sm">
|
||||
{activeSource}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium mb-2">Change Active Source:</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.keys(dataSources).map(name => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => handleSetActiveSource(name)}
|
||||
disabled={activeSource === name || saving}
|
||||
className={`px-4 py-2 rounded ${
|
||||
activeSource === name
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 hover:bg-gray-300 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Configured Sources</h3>
|
||||
|
||||
{Object.keys(dataSources).length === 0 ? (
|
||||
<p className="text-gray-500">No data sources configured</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{Object.entries(dataSources).map(([name, source]) => (
|
||||
<div key={name} className="border rounded p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<h4 className="font-medium">{name}</h4>
|
||||
<p className="text-sm text-gray-600">Type: {source.type}</p>
|
||||
<p className="text-sm text-gray-600">URL: {source.url}</p>
|
||||
{source.basic_auth?.username && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Basic Auth: {source.basic_auth.username}
|
||||
</p>
|
||||
)}
|
||||
{renderConnectionStatus(name)}
|
||||
</div>
|
||||
<div className="mt-2 sm:mt-0">
|
||||
<button
|
||||
onClick={() => testConnection(name, source)}
|
||||
className="mr-2 text-green-600 hover:text-green-800"
|
||||
disabled={saving}
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditSource(name)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
disabled={editSource === name || saving}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editSource === name && (
|
||||
<form onSubmit={handleUpdateDataSource} className="mt-4 border-t pt-4">
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Type
|
||||
</label>
|
||||
<select
|
||||
name="type"
|
||||
value={sourceForm.type}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={saving}
|
||||
>
|
||||
<option value="pangolin">Pangolin API</option>
|
||||
<option value="traefik">Traefik API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="url"
|
||||
value={sourceForm.url}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={sourceForm.type === 'pangolin'
|
||||
? 'http://pangolin:3001/api/v1'
|
||||
: 'http://traefik:8080'}
|
||||
required
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{sourceForm.type === 'pangolin'
|
||||
? 'Pangolin API URL (e.g., http://pangolin:3001/api/v1)'
|
||||
: 'Traefik API URL (e.g., http://traefik:8080)'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{sourceForm.type === 'traefik' &&
|
||||
'Docker container access: Use http://traefik:8080 when both containers are on the same network'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Basic Auth Username (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="basicAuth.username"
|
||||
value={sourceForm.basicAuth.username}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Username"
|
||||
disabled={saving}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Basic Auth Password (optional)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="basicAuth.password"
|
||||
value={sourceForm.basicAuth.password}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Password"
|
||||
disabled={saving}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Leave empty to keep the existing password
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEdit}
|
||||
className="w-full sm:w-auto px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestFormConnection}
|
||||
className="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
disabled={saving || !sourceForm.url}
|
||||
>
|
||||
Test Connection
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 border-l-4 border-blue-400 text-blue-700">
|
||||
<h4 className="font-semibold mb-1">Troubleshooting Connection Issues</h4>
|
||||
<ul className="list-disc ml-5 text-sm">
|
||||
<li>For Docker containers, use <code className="bg-blue-100 px-1 rounded">http://traefik:8080</code> instead of localhost</li>
|
||||
<li>For Pangolin, use <code className="bg-blue-100 px-1 rounded">http://pangolin:3001/api/v1</code></li>
|
||||
<li>Ensure container names match those in your docker-compose file</li>
|
||||
<li>Check if Traefik API is enabled with <code className="bg-blue-100 px-1 rounded">--api.insecure=true</code> flag</li>
|
||||
<li>Verify that both containers are on the same Docker network</li>
|
||||
<li>From command line, testing with curl commands can help identify issues</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataSourceSettings;
|
||||
@@ -17,6 +17,8 @@ export const AppProvider = ({ children }) => {
|
||||
const [middlewareId, setMiddlewareId] = useState(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [activeDataSource, setActiveDataSource] = useState('pangolin');
|
||||
|
||||
// Initialize dark mode on mount
|
||||
useEffect(() => {
|
||||
@@ -35,6 +37,28 @@ export const AppProvider = ({ children }) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch active data source on mount
|
||||
useEffect(() => {
|
||||
fetchActiveDataSource();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch the active data source from the API
|
||||
*/
|
||||
const fetchActiveDataSource = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/datasource/active');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setActiveDataSource(data.name || 'pangolin');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch active data source:', error);
|
||||
// Default to pangolin if there's an error
|
||||
setActiveDataSource('pangolin');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to a different page
|
||||
*
|
||||
@@ -67,8 +91,13 @@ export const AppProvider = ({ children }) => {
|
||||
middlewareId,
|
||||
isEditing,
|
||||
isDarkMode,
|
||||
showSettings,
|
||||
activeDataSource,
|
||||
setIsDarkMode,
|
||||
navigateTo
|
||||
setShowSettings,
|
||||
setActiveDataSource,
|
||||
navigateTo,
|
||||
fetchActiveDataSource
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
176
ui/src/contexts/DataSourceContext.js
Normal file
176
ui/src/contexts/DataSourceContext.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
|
||||
|
||||
// Create the context
|
||||
const DataSourceContext = createContext();
|
||||
|
||||
/**
|
||||
* DataSource provider component
|
||||
* Manages data source state and API interactions
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {ReactNode} props.children - Child components
|
||||
* @returns {JSX.Element}
|
||||
*/
|
||||
export const DataSourceProvider = ({ children }) => {
|
||||
const [dataSources, setDataSources] = useState({});
|
||||
const [activeSource, setActiveSource] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
/**
|
||||
* Fetch all data sources
|
||||
*/
|
||||
const fetchDataSources = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/datasource');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDataSources(data.sources || {});
|
||||
setActiveSource(data.active_source || '');
|
||||
|
||||
} catch (err) {
|
||||
setError('Failed to load data sources');
|
||||
console.error('Error fetching data sources:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch active data source
|
||||
*/
|
||||
const fetchActiveDataSource = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/datasource/active');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setActiveSource(data.name || '');
|
||||
|
||||
} catch (err) {
|
||||
setError('Failed to load active data source');
|
||||
console.error('Error fetching active data source:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Set the active data source
|
||||
*
|
||||
* @param {string} name - Data source name
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
const setActiveDataSource = useCallback(async (name) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch('/api/datasource/active', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
// Update local state
|
||||
setActiveSource(name);
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to set active data source: ${err.message}`);
|
||||
console.error('Error setting active data source:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update a data source configuration
|
||||
*
|
||||
* @param {string} name - Data source name
|
||||
* @param {Object} config - Data source configuration
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
const updateDataSource = useCallback(async (name, config) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/datasource/${name}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error ${response.status}`);
|
||||
}
|
||||
|
||||
// Update local data sources list
|
||||
await fetchDataSources();
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to update data source: ${err.message}`);
|
||||
console.error('Error updating data source:', err);
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchDataSources]);
|
||||
|
||||
// Fetch data sources on initial mount
|
||||
useEffect(() => {
|
||||
fetchDataSources();
|
||||
}, [fetchDataSources]);
|
||||
|
||||
// Create context value object
|
||||
const value = {
|
||||
dataSources,
|
||||
activeSource,
|
||||
loading,
|
||||
error,
|
||||
fetchDataSources,
|
||||
fetchActiveDataSource,
|
||||
setActiveDataSource,
|
||||
updateDataSource,
|
||||
setError, // Expose setError for components to clear errors
|
||||
};
|
||||
|
||||
return (
|
||||
<DataSourceContext.Provider value={value}>
|
||||
{children}
|
||||
</DataSourceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to use the data source context
|
||||
*
|
||||
* @returns {Object} Data source context value
|
||||
*/
|
||||
export const useDataSource = () => {
|
||||
const context = useContext(DataSourceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useDataSource must be used within a DataSourceProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -5,4 +5,5 @@
|
||||
|
||||
export { AppProvider, useApp } from './AppContext';
|
||||
export { ResourceProvider, useResources } from './ResourceContext';
|
||||
export { MiddlewareProvider, useMiddlewares } from './MiddlewareContext';
|
||||
export { MiddlewareProvider, useMiddlewares } from './MiddlewareContext';
|
||||
export { DataSourceProvider, useDataSource } from './DataSourceContext';
|
||||
Reference in New Issue
Block a user