This commit is contained in:
hhftechnologies
2025-04-29 11:13:11 +05:30
parent 7156fdc34f
commit 88a2a02a0d
23 changed files with 1700 additions and 95 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View File

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

View File

@@ -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"`
// }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
};

View File

@@ -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';