refactoring

This commit is contained in:
hhftechnologies
2025-04-22 16:25:17 +05:30
parent effc0b14a6
commit 10a2cf3ce4
33 changed files with 5595 additions and 3973 deletions

View File

@@ -3,8 +3,6 @@ name: Build and Push Docker Image
on:
push:
branches:
- main
- master
- dev
paths:
- 'Dockerfile'
@@ -19,9 +17,6 @@ on:
- 'models/**'
- 'services/**'
- 'ui/**'
tags:
- 'v*'
workflow_dispatch:
env:
@@ -53,48 +48,14 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Get current date for image tags
- name: Get current date
id: date
run: echo "date=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
# Prepare tags based on branch and version
- name: Prepare Docker tags
id: docker_tags
run: |
TAGS=""
# Add branch-specific tags
if [[ "${{ github.ref_name }}" == "main" || "${{ github.ref_name }}" == "master" ]]; then
# For main/master branch, add latest tag
TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:latest"
elif [[ "${{ github.ref_name }}" == "dev" ]]; then
# For dev branch
TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:dev"
elif [[ "${{ github.ref_type }}" == "branch" ]]; then
# For other branches
TAGS="$TAGS ${{ env.DOCKERHUB_IMAGE_NAME }}:${{ github.ref_name }}"
if [[ "${{ github.ref_type }}" == "branch" && "${{ github.ref_name }}" == "dev" ]]; then
TAGS="${{ env.DOCKERHUB_IMAGE_NAME }}:dev"
fi
# Add sha tag for all branches
if [[ "${{ github.ref_type }}" == "branch" ]]; then
TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:sha-${GITHUB_SHA::7}"
fi
# Add version tag for tagged releases
if [[ "${{ github.ref_type }}" == "tag" && "${{ github.ref }}" == refs/tags/v* ]]; then
VERSION="${{ github.ref_name }}"
# Add full version tag
TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:$VERSION"
fi
# Add date tag for all builds
TAGS="$TAGS,${{ env.DOCKERHUB_IMAGE_NAME }}:${{ steps.date.outputs.date }}"
# Remove leading space or comma if present
TAGS=$(echo "$TAGS" | sed 's/^[ ,]*//')
echo "tags=$TAGS" >> $GITHUB_OUTPUT
echo "Docker tags: $TAGS"
@@ -102,8 +63,8 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
push: true
push: ${{ steps.docker_tags.outputs.tags != '' }} # IMPORTANT: Only push if tags were generated
platforms: linux/amd64,linux/arm64
tags: ${{ steps.docker_tags.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: type=gha,mode=max

90
api/errors/errors.go Normal file
View File

@@ -0,0 +1,90 @@
package errors
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// APIError represents a standardized error response
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"` // Optional detailed error info
}
// HandleAPIError logs and returns a standardized error response
func HandleAPIError(c *gin.Context, statusCode int, message string, err error) {
// Log the error with details
if err != nil {
log.Printf("API Error: %s - %v", message, err)
// Respond to the client with error details
c.JSON(statusCode, APIError{
Code: statusCode,
Message: message,
Details: err.Error(),
})
} else {
// Log just the message if no specific error is provided
log.Printf("API Error: %s", message)
// Respond to the client without error details
c.JSON(statusCode, APIError{
Code: statusCode,
Message: message,
})
}
}
// NotFound handles not found errors (404)
func NotFound(c *gin.Context, resourceType, id string) {
message := resourceType + " not found"
if id != "" {
message += ": " + id
}
HandleAPIError(c, http.StatusNotFound, message, nil)
}
// BadRequest handles bad request errors (400)
func BadRequest(c *gin.Context, message string, err error) {
HandleAPIError(c, http.StatusBadRequest, message, err)
}
// ServerError handles internal server errors (500)
func ServerError(c *gin.Context, message string, err error) {
HandleAPIError(c, http.StatusInternalServerError, message, err)
}
// Unauthorized handles unauthorized errors (401)
func Unauthorized(c *gin.Context, message string) {
if message == "" {
message = "Unauthorized access"
}
HandleAPIError(c, http.StatusUnauthorized, message, nil)
}
// Forbidden handles forbidden errors (403)
func Forbidden(c *gin.Context, message string) {
if message == "" {
message = "Access forbidden"
}
HandleAPIError(c, http.StatusForbidden, message, nil)
}
// Conflict handles conflict errors (409)
func Conflict(c *gin.Context, message string, err error) {
HandleAPIError(c, http.StatusConflict, message, err)
}
// UnprocessableEntity handles validation errors (422)
func UnprocessableEntity(c *gin.Context, message string, err error) {
HandleAPIError(c, http.StatusUnprocessableEntity, message, err)
}
// ServiceUnavailable handles service unavailable errors (503)
func ServiceUnavailable(c *gin.Context, message string, err error) {
HandleAPIError(c, http.StatusServiceUnavailable, message, err)
}

File diff suppressed because it is too large Load Diff

117
api/handlers/common.go Normal file
View File

@@ -0,0 +1,117 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"strings"
"github.com/gin-gonic/gin"
)
// APIError represents a standardized error response
type APIError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// ResponseWithError sends a standardized error response
func ResponseWithError(c *gin.Context, statusCode int, message string) {
c.JSON(statusCode, APIError{
Code: statusCode,
Message: message,
})
}
// generateID generates a random 16-character hex string
func generateID() (string, error) {
bytes := make([]byte, 8)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// isValidMiddlewareType checks if a middleware type is valid
func isValidMiddlewareType(typ string) bool {
validTypes := map[string]bool{
"basicAuth": true,
"forwardAuth": true,
"ipWhiteList": true,
"rateLimit": true,
"headers": true,
"stripPrefix": true,
"addPrefix": true,
"redirectRegex": true,
"redirectScheme": true,
"chain": true,
"replacepathregex": true,
"plugin": true,
}
return validTypes[typ]
}
// sanitizeMiddlewareConfig ensures proper formatting of duration values and strings
func sanitizeMiddlewareConfig(config map[string]interface{}) {
// List of keys that should be treated as duration values
durationKeys := map[string]bool{
"checkPeriod": true,
"fallbackDuration": true,
"recoveryDuration": true,
"initialInterval": true,
"retryTimeout": true,
"gracePeriod": true,
}
// Process the configuration recursively
sanitizeConfigRecursive(config, durationKeys)
}
// sanitizeConfigRecursive processes config values recursively
func sanitizeConfigRecursive(data interface{}, durationKeys map[string]bool) {
// Process based on data type
switch v := data.(type) {
case map[string]interface{}:
// Process each key-value pair in the map
for key, value := range v {
// Handle different value types
switch innerVal := value.(type) {
case string:
// Check if this is a duration field and ensure proper format
if durationKeys[key] {
// Check if the string has extra quotes
if len(innerVal) > 2 && strings.HasPrefix(innerVal, "\"") && strings.HasSuffix(innerVal, "\"") {
// Remove the extra quotes
v[key] = strings.Trim(innerVal, "\"")
}
}
case map[string]interface{}, []interface{}:
// Recursively process nested structures
sanitizeConfigRecursive(innerVal, durationKeys)
}
}
case []interface{}:
// Process each item in the array
for i, item := range v {
switch innerVal := item.(type) {
case map[string]interface{}, []interface{}:
// Recursively process nested structures
sanitizeConfigRecursive(innerVal, durationKeys)
case string:
// Check if string has unnecessary quotes
if len(innerVal) > 2 && strings.HasPrefix(innerVal, "\"") && strings.HasSuffix(innerVal, "\"") {
v[i] = strings.Trim(innerVal, "\"")
}
}
}
}
}
// LogError logs an error with context information
func LogError(context string, err error) {
if err != nil {
log.Printf("Error %s: %v", context, err)
}
}

495
api/handlers/config.go Normal file
View File

@@ -0,0 +1,495 @@
package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// ConfigHandler handles configuration-related requests
type ConfigHandler struct {
DB *sql.DB
}
// NewConfigHandler creates a new config handler
func NewConfigHandler(db *sql.DB) *ConfigHandler {
return &ConfigHandler{DB: db}
}
// UpdateRouterPriority updates the router priority for a resource
func (h *ConfigHandler) UpdateRouterPriority(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
RouterPriority int `json:"router_priority" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow updating disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
return
}
// Update the resource within a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Updating router priority for resource %s to %d", id, input.RouterPriority)
result, txErr := tx.Exec(
"UPDATE resources SET router_priority = ?, updated_at = ? WHERE id = ?",
input.RouterPriority, time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating router priority: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update router priority")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully updated router priority for resource %s", id)
c.JSON(http.StatusOK, gin.H{
"id": id,
"router_priority": input.RouterPriority,
})
}
// UpdateHTTPConfig updates the HTTP router entrypoints configuration
func (h *ConfigHandler) UpdateHTTPConfig(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
Entrypoints string `json:"entrypoints"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow updating disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
return
}
// Validate entrypoints - should be comma-separated list
if input.Entrypoints == "" {
input.Entrypoints = "websecure" // Default
}
// Update the resource within a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Updating HTTP entrypoints for resource %s: %s", id, input.Entrypoints)
result, txErr := tx.Exec(
"UPDATE resources SET entrypoints = ?, updated_at = ? WHERE id = ?",
input.Entrypoints, time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating resource entrypoints: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update resource")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
if rowsAffected == 0 {
log.Printf("Warning: Update query succeeded but no rows were affected")
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully updated HTTP entrypoints for resource %s", id)
c.JSON(http.StatusOK, gin.H{
"id": id,
"entrypoints": input.Entrypoints,
})
}
// UpdateTLSConfig updates the TLS certificate domains configuration
func (h *ConfigHandler) UpdateTLSConfig(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
TLSDomains string `json:"tls_domains"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow updating disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
return
}
// Update the resource within a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Updating TLS domains for resource %s: %s", id, input.TLSDomains)
result, txErr := tx.Exec(
"UPDATE resources SET tls_domains = ?, updated_at = ? WHERE id = ?",
input.TLSDomains, time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating TLS domains: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update TLS domains")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
if rowsAffected == 0 {
log.Printf("Warning: Update query succeeded but no rows were affected")
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully updated TLS domains for resource %s", id)
c.JSON(http.StatusOK, gin.H{
"id": id,
"tls_domains": input.TLSDomains,
})
}
// UpdateTCPConfig updates the TCP SNI router configuration
func (h *ConfigHandler) UpdateTCPConfig(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
TCPEnabled bool `json:"tcp_enabled"`
TCPEntrypoints string `json:"tcp_entrypoints"`
TCPSNIRule string `json:"tcp_sni_rule"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow updating disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
return
}
// Validate TCP entrypoints if provided
if input.TCPEntrypoints == "" {
input.TCPEntrypoints = "tcp" // Default
}
// Convert boolean to integer for SQLite
tcpEnabled := 0
if input.TCPEnabled {
tcpEnabled = 1
}
// Update the resource within a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Updating TCP config for resource %s: enabled=%t, entrypoints=%s",
id, input.TCPEnabled, input.TCPEntrypoints)
result, txErr := tx.Exec(
"UPDATE resources SET tcp_enabled = ?, tcp_entrypoints = ?, tcp_sni_rule = ?, updated_at = ? WHERE id = ?",
tcpEnabled, input.TCPEntrypoints, input.TCPSNIRule, time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating TCP config: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update TCP configuration")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
if rowsAffected == 0 {
log.Printf("Warning: Update query succeeded but no rows were affected")
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully updated TCP configuration for resource %s", id)
c.JSON(http.StatusOK, gin.H{
"id": id,
"tcp_enabled": input.TCPEnabled,
"tcp_entrypoints": input.TCPEntrypoints,
"tcp_sni_rule": input.TCPSNIRule,
})
}
// UpdateHeadersConfig updates the custom headers configuration
func (h *ConfigHandler) UpdateHeadersConfig(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
CustomHeaders map[string]string `json:"custom_headers" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", id).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow updating disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot update a disabled resource")
return
}
// Convert headers to JSON for storage
headersJSON, err := json.Marshal(input.CustomHeaders)
if err != nil {
log.Printf("Error encoding headers: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to encode headers")
return
}
// Update the resource within a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Updating custom headers for resource %s with %d headers",
id, len(input.CustomHeaders))
result, txErr := tx.Exec(
"UPDATE resources SET custom_headers = ?, updated_at = ? WHERE id = ?",
string(headersJSON), time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating custom headers: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update custom headers")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
if rowsAffected == 0 {
log.Printf("Warning: Update query succeeded but no rows were affected")
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Verify the update by reading back the custom_headers
var storedHeaders string
verifyErr := h.DB.QueryRow("SELECT custom_headers FROM resources WHERE id = ?", id).Scan(&storedHeaders)
if verifyErr != nil {
log.Printf("Warning: Could not verify headers update: %v", verifyErr)
} else if storedHeaders == "" {
log.Printf("Warning: Headers may be empty after update for resource %s", id)
} else {
log.Printf("Successfully verified headers update for resource %s", id)
}
log.Printf("Successfully updated custom headers for resource %s", id)
c.JSON(http.StatusOK, gin.H{
"id": id,
"custom_headers": input.CustomHeaders,
})
}

367
api/handlers/middlewares.go Normal file
View File

@@ -0,0 +1,367 @@
package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// MiddlewareHandler handles middleware-related requests
type MiddlewareHandler struct {
DB *sql.DB
}
// NewMiddlewareHandler creates a new middleware handler
func NewMiddlewareHandler(db *sql.DB) *MiddlewareHandler {
return &MiddlewareHandler{DB: db}
}
// GetMiddlewares returns all middleware configurations
func (h *MiddlewareHandler) GetMiddlewares(c *gin.Context) {
rows, err := h.DB.Query("SELECT id, name, type, config FROM middlewares")
if err != nil {
log.Printf("Error fetching middlewares: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch middlewares")
return
}
defer rows.Close()
middlewares := []map[string]interface{}{}
for rows.Next() {
var id, name, typ, configStr string
if err := rows.Scan(&id, &name, &typ, &configStr); err != nil {
log.Printf("Error scanning middleware row: %v", err)
continue
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &config); err != nil {
log.Printf("Error parsing middleware config: %v", err)
config = map[string]interface{}{}
}
middlewares = append(middlewares, map[string]interface{}{
"id": id,
"name": name,
"type": typ,
"config": config,
})
}
if err := rows.Err(); err != nil {
log.Printf("Error iterating middleware rows: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error while fetching middlewares")
return
}
c.JSON(http.StatusOK, middlewares)
}
// CreateMiddleware creates a new middleware configuration
func (h *MiddlewareHandler) CreateMiddleware(c *gin.Context) {
var middleware struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Config map[string]interface{} `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&middleware); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Validate middleware type
if !isValidMiddlewareType(middleware.Type) {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid middleware type: %s", middleware.Type))
return
}
// Generate a unique ID
id, err := generateID()
if err != nil {
log.Printf("Error generating ID: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to generate ID")
return
}
// Convert config to JSON string
configJSON, err := json.Marshal(middleware.Config)
if err != nil {
log.Printf("Error encoding config: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to encode config")
return
}
// Insert into database using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Attempting to insert middleware with ID=%s, name=%s, type=%s",
id, middleware.Name, middleware.Type)
result, txErr := tx.Exec(
"INSERT INTO middlewares (id, name, type, config) VALUES (?, ?, ?, ?)",
id, middleware.Name, middleware.Type, string(configJSON),
)
if txErr != nil {
log.Printf("Error inserting middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to save middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Insert affected %d rows", rowsAffected)
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully created middleware %s (%s)", middleware.Name, id)
c.JSON(http.StatusCreated, gin.H{
"id": id,
"name": middleware.Name,
"type": middleware.Type,
"config": middleware.Config,
})
}
// GetMiddleware returns a specific middleware configuration
func (h *MiddlewareHandler) GetMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
var name, typ, configStr string
err := h.DB.QueryRow("SELECT name, type, config FROM middlewares WHERE id = ?", id).Scan(&name, &typ, &configStr)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
} else if err != nil {
log.Printf("Error fetching middleware: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch middleware")
return
}
var config map[string]interface{}
if err := json.Unmarshal([]byte(configStr), &config); err != nil {
log.Printf("Error parsing middleware config: %v", err)
config = map[string]interface{}{}
}
c.JSON(http.StatusOK, gin.H{
"id": id,
"name": name,
"type": typ,
"config": config,
})
}
// UpdateMiddleware updates a middleware configuration
func (h *MiddlewareHandler) UpdateMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
var middleware struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
Config map[string]interface{} `json:"config" binding:"required"`
}
if err := c.ShouldBindJSON(&middleware); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Validate middleware type
if !isValidMiddlewareType(middleware.Type) {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid middleware type: %s", middleware.Type))
return
}
// Check if middleware exists
var exists int
err := h.DB.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", id).Scan(&exists)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
} else if err != nil {
log.Printf("Error checking middleware existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Convert config to JSON string
configJSON, err := json.Marshal(middleware.Config)
if err != nil {
log.Printf("Error encoding config: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to encode config")
return
}
// Update in database using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Attempting to update middleware %s with name=%s, type=%s",
id, middleware.Name, middleware.Type)
result, txErr := tx.Exec(
"UPDATE middlewares SET name = ?, type = ?, config = ?, updated_at = ? WHERE id = ?",
middleware.Name, middleware.Type, string(configJSON), time.Now(), id,
)
if txErr != nil {
log.Printf("Error updating middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to update middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Update affected %d rows", rowsAffected)
if rowsAffected == 0 {
log.Printf("Warning: Update query succeeded but no rows were affected")
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Double-check that the middleware was updated
var updatedName string
err = h.DB.QueryRow("SELECT name FROM middlewares WHERE id = ?", id).Scan(&updatedName)
if err != nil {
log.Printf("Warning: Could not verify middleware update: %v", err)
} else if updatedName != middleware.Name {
log.Printf("Warning: Name mismatch after update. Expected '%s', got '%s'", middleware.Name, updatedName)
} else {
log.Printf("Successfully verified middleware update for %s", id)
}
// Return the updated middleware
c.JSON(http.StatusOK, gin.H{
"id": id,
"name": middleware.Name,
"type": middleware.Type,
"config": middleware.Config,
})
}
// DeleteMiddleware deletes a middleware configuration
func (h *MiddlewareHandler) DeleteMiddleware(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Middleware ID is required")
return
}
// Check for dependencies first
var count int
err := h.DB.QueryRow("SELECT COUNT(*) FROM resource_middlewares WHERE middleware_id = ?", id).Scan(&count)
if err != nil {
log.Printf("Error checking middleware dependencies: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if count > 0 {
ResponseWithError(c, http.StatusConflict, fmt.Sprintf("Cannot delete middleware because it is used by %d resources", count))
return
}
// Delete from database using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
log.Printf("Attempting to delete middleware %s", id)
result, txErr := tx.Exec("DELETE FROM middlewares WHERE id = ?", id)
if txErr != nil {
log.Printf("Error deleting middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("Error getting rows affected: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if rowsAffected == 0 {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
}
log.Printf("Delete affected %d rows", rowsAffected)
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully deleted middleware %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Middleware deleted successfully"})
}

561
api/handlers/resources.go Normal file
View File

@@ -0,0 +1,561 @@
package handlers
import (
"database/sql"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// ResourceHandler handles resource-related requests
type ResourceHandler struct {
DB *sql.DB
}
// NewResourceHandler creates a new resource handler
func NewResourceHandler(db *sql.DB) *ResourceHandler {
return &ResourceHandler{DB: db}
}
// GetResources returns all resources and their assigned middlewares
func (h *ResourceHandler) GetResources(c *gin.Context) {
rows, err := h.DB.Query(`
SELECT r.id, r.host, r.service_id, r.org_id, r.site_id, r.status,
r.entrypoints, r.tls_domains, r.tcp_enabled, r.tcp_entrypoints, r.tcp_sni_rule,
r.custom_headers, r.router_priority,
GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares
FROM resources r
LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
LEFT JOIN middlewares m ON rm.middleware_id = m.id
GROUP BY r.id
`)
if err != nil {
log.Printf("Error fetching resources: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch resources")
return
}
defer rows.Close()
var resources []map[string]interface{}
for rows.Next() {
var id, host, serviceID, orgID, siteID, status, entrypoints, tlsDomains, tcpEntrypoints, tcpSNIRule, customHeaders string
var tcpEnabled int
var routerPriority sql.NullInt64
var middlewares sql.NullString
if err := rows.Scan(&id, &host, &serviceID, &orgID, &siteID, &status,
&entrypoints, &tlsDomains, &tcpEnabled, &tcpEntrypoints, &tcpSNIRule,
&customHeaders, &routerPriority, &middlewares); err != nil {
log.Printf("Error scanning resource row: %v", err)
continue
}
// Use default priority if null
priority := 100 // Default value
if routerPriority.Valid {
priority = int(routerPriority.Int64)
}
resource := map[string]interface{}{
"id": id,
"host": host,
"service_id": serviceID,
"org_id": orgID,
"site_id": siteID,
"status": status,
"entrypoints": entrypoints,
"tls_domains": tlsDomains,
"tcp_enabled": tcpEnabled > 0,
"tcp_entrypoints": tcpEntrypoints,
"tcp_sni_rule": tcpSNIRule,
"custom_headers": customHeaders,
"router_priority": priority,
}
if middlewares.Valid {
resource["middlewares"] = middlewares.String
} else {
resource["middlewares"] = ""
}
resources = append(resources, resource)
}
if err := rows.Err(); err != nil {
log.Printf("Error during resource rows iteration: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch resources")
return
}
c.JSON(http.StatusOK, resources)
}
// GetResource returns a specific resource
func (h *ResourceHandler) GetResource(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var host, serviceID, orgID, siteID, status, entrypoints, tlsDomains, tcpEntrypoints, tcpSNIRule, customHeaders string
var tcpEnabled int
var routerPriority sql.NullInt64
var middlewares sql.NullString
err := h.DB.QueryRow(`
SELECT r.host, r.service_id, r.org_id, r.site_id, r.status,
r.entrypoints, r.tls_domains, r.tcp_enabled, r.tcp_entrypoints, r.tcp_sni_rule,
r.custom_headers, r.router_priority,
GROUP_CONCAT(m.id || ':' || m.name || ':' || rm.priority, ',') as middlewares
FROM resources r
LEFT JOIN resource_middlewares rm ON r.id = rm.resource_id
LEFT JOIN middlewares m ON rm.middleware_id = m.id
WHERE r.id = ?
GROUP BY r.id
`, id).Scan(&host, &serviceID, &orgID, &siteID, &status,
&entrypoints, &tlsDomains, &tcpEnabled, &tcpEntrypoints, &tcpSNIRule,
&customHeaders, &routerPriority, &middlewares)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, fmt.Sprintf("Resource not found: %s", id))
return
} else if err != nil {
log.Printf("Error fetching resource: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Failed to fetch resource")
return
}
// Use default priority if null
priority := 100 // Default value
if routerPriority.Valid {
priority = int(routerPriority.Int64)
}
resource := map[string]interface{}{
"id": id,
"host": host,
"service_id": serviceID,
"org_id": orgID,
"site_id": siteID,
"status": status,
"entrypoints": entrypoints,
"tls_domains": tlsDomains,
"tcp_enabled": tcpEnabled > 0,
"tcp_entrypoints": tcpEntrypoints,
"tcp_sni_rule": tcpSNIRule,
"custom_headers": customHeaders,
"router_priority": priority,
}
if middlewares.Valid {
resource["middlewares"] = middlewares.String
} else {
resource["middlewares"] = ""
}
c.JSON(http.StatusOK, resource)
}
// DeleteResource deletes a resource from the database
func (h *ResourceHandler) DeleteResource(c *gin.Context) {
id := c.Param("id")
if id == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
// Check if resource exists and its status
var status string
err := h.DB.QueryRow("SELECT status FROM resources WHERE id = ?", id).Scan(&status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Only allow deletion of disabled resources
if status != "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Only disabled resources can be deleted")
return
}
// Delete the resource using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// First delete any middleware relationships
log.Printf("Removing middleware relationships for resource %s", id)
_, txErr = tx.Exec("DELETE FROM resource_middlewares WHERE resource_id = ?", id)
if txErr != nil {
log.Printf("Error removing resource middlewares: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resource")
return
}
// Then delete the resource
log.Printf("Deleting resource %s", id)
result, txErr := tx.Exec("DELETE FROM resources WHERE id = ?", id)
if txErr != nil {
log.Printf("Error deleting resource: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to delete resource")
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("Error getting rows affected: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if rowsAffected == 0 {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
}
log.Printf("Delete affected %d rows", rowsAffected)
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully deleted resource %s", id)
c.JSON(http.StatusOK, gin.H{"message": "Resource deleted successfully"})
}
// AssignMiddleware assigns a middleware to a resource
func (h *ResourceHandler) AssignMiddleware(c *gin.Context) {
resourceID := c.Param("id")
if resourceID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
MiddlewareID string `json:"middleware_id" binding:"required"`
Priority int `json:"priority"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Default priority is 100 if not specified
if input.Priority <= 0 {
input.Priority = 100
}
// Verify resource exists
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", resourceID).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow attaching middlewares to disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot assign middleware to a disabled resource")
return
}
// Verify middleware exists
err = h.DB.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", input.MiddlewareID).Scan(&exists)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Middleware not found")
return
} else if err != nil {
log.Printf("Error checking middleware existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Insert or update the resource middleware relationship using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// First delete any existing relationship
log.Printf("Removing existing middleware relationship: resource=%s, middleware=%s",
resourceID, input.MiddlewareID)
_, txErr = tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, input.MiddlewareID,
)
if txErr != nil {
log.Printf("Error removing existing relationship: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Then insert the new relationship
log.Printf("Creating new middleware relationship: resource=%s, middleware=%s, priority=%d",
resourceID, input.MiddlewareID, input.Priority)
result, txErr := tx.Exec(
"INSERT INTO resource_middlewares (resource_id, middleware_id, priority) VALUES (?, ?, ?)",
resourceID, input.MiddlewareID, input.Priority,
)
if txErr != nil {
log.Printf("Error assigning middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil {
log.Printf("Insert affected %d rows", rowsAffected)
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully assigned middleware %s to resource %s with priority %d",
input.MiddlewareID, resourceID, input.Priority)
c.JSON(http.StatusOK, gin.H{
"resource_id": resourceID,
"middleware_id": input.MiddlewareID,
"priority": input.Priority,
})
}
// AssignMultipleMiddlewares assigns multiple middlewares to a resource in one operation
func (h *ResourceHandler) AssignMultipleMiddlewares(c *gin.Context) {
resourceID := c.Param("id")
if resourceID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID is required")
return
}
var input struct {
Middlewares []struct {
MiddlewareID string `json:"middleware_id" binding:"required"`
Priority int `json:"priority"`
} `json:"middlewares" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
ResponseWithError(c, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
// Verify resource exists and is active
var exists int
var status string
err := h.DB.QueryRow("SELECT 1, status FROM resources WHERE id = ?", resourceID).Scan(&exists, &status)
if err == sql.ErrNoRows {
ResponseWithError(c, http.StatusNotFound, "Resource not found")
return
} else if err != nil {
log.Printf("Error checking resource existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Don't allow attaching middlewares to disabled resources
if status == "disabled" {
ResponseWithError(c, http.StatusBadRequest, "Cannot assign middlewares to a disabled resource")
return
}
// Start a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
// Process each middleware
successful := make([]map[string]interface{}, 0)
log.Printf("Assigning %d middlewares to resource %s", len(input.Middlewares), resourceID)
for _, mw := range input.Middlewares {
// Default priority is 100 if not specified
if mw.Priority <= 0 {
mw.Priority = 100
}
// Verify middleware exists
var middlewareExists int
err := h.DB.QueryRow("SELECT 1 FROM middlewares WHERE id = ?", mw.MiddlewareID).Scan(&middlewareExists)
if err == sql.ErrNoRows {
// Skip this middleware but don't fail the entire request
log.Printf("Middleware %s not found, skipping", mw.MiddlewareID)
continue
} else if err != nil {
log.Printf("Error checking middleware existence: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// First delete any existing relationship
log.Printf("Removing existing relationship: resource=%s, middleware=%s",
resourceID, mw.MiddlewareID)
_, txErr = tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, mw.MiddlewareID,
)
if txErr != nil {
log.Printf("Error removing existing relationship: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// Then insert the new relationship
log.Printf("Creating new relationship: resource=%s, middleware=%s, priority=%d",
resourceID, mw.MiddlewareID, mw.Priority)
result, txErr := tx.Exec(
"INSERT INTO resource_middlewares (resource_id, middleware_id, priority) VALUES (?, ?, ?)",
resourceID, mw.MiddlewareID, mw.Priority,
)
if txErr != nil {
log.Printf("Error assigning middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to assign middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err == nil && rowsAffected > 0 {
log.Printf("Successfully assigned middleware %s with priority %d",
mw.MiddlewareID, mw.Priority)
successful = append(successful, map[string]interface{}{
"middleware_id": mw.MiddlewareID,
"priority": mw.Priority,
})
} else {
log.Printf("Warning: Insertion query succeeded but affected %d rows", rowsAffected)
}
}
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully assigned %d middlewares to resource %s", len(successful), resourceID)
c.JSON(http.StatusOK, gin.H{
"resource_id": resourceID,
"middlewares": successful,
})
}
// RemoveMiddleware removes a middleware from a resource
func (h *ResourceHandler) RemoveMiddleware(c *gin.Context) {
resourceID := c.Param("id")
middlewareID := c.Param("middlewareId")
if resourceID == "" || middlewareID == "" {
ResponseWithError(c, http.StatusBadRequest, "Resource ID and Middleware ID are required")
return
}
log.Printf("Removing middleware %s from resource %s", middlewareID, resourceID)
// Delete the relationship using a transaction
tx, err := h.DB.Begin()
if err != nil {
log.Printf("Error beginning transaction: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
// If something goes wrong, rollback
var txErr error
defer func() {
if txErr != nil {
tx.Rollback()
log.Printf("Transaction rolled back due to error: %v", txErr)
}
}()
result, txErr := tx.Exec(
"DELETE FROM resource_middlewares WHERE resource_id = ? AND middleware_id = ?",
resourceID, middlewareID,
)
if txErr != nil {
log.Printf("Error removing middleware: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Failed to remove middleware")
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
log.Printf("Error getting rows affected: %v", err)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
if rowsAffected == 0 {
log.Printf("No relationship found between resource %s and middleware %s", resourceID, middlewareID)
ResponseWithError(c, http.StatusNotFound, "Resource middleware relationship not found")
return
}
log.Printf("Delete affected %d rows", rowsAffected)
// Commit the transaction
if txErr = tx.Commit(); txErr != nil {
log.Printf("Error committing transaction: %v", txErr)
ResponseWithError(c, http.StatusInternalServerError, "Database error")
return
}
log.Printf("Successfully removed middleware %s from resource %s", middlewareID, resourceID)
c.JSON(http.StatusOK, gin.H{"message": "Middleware removed from resource successfully"})
}

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"database/sql"
"log"
"net/http"
"os"
@@ -12,14 +13,17 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"github.com/hhftechnology/middleware-manager/database"
"github.com/hhftechnology/middleware-manager/api/handlers"
)
// Server represents the API server
type Server struct {
db *database.DB
router *gin.Engine
srv *http.Server
db *sql.DB
router *gin.Engine
srv *http.Server
middlewareHandler *handlers.MiddlewareHandler
resourceHandler *handlers.ResourceHandler
configHandler *handlers.ConfigHandler
}
// ServerConfig contains configuration options for the server
@@ -32,7 +36,7 @@ type ServerConfig struct {
}
// NewServer creates a new API server
func NewServer(db *database.DB, config ServerConfig) *Server {
func NewServer(db *sql.DB, config ServerConfig) *Server {
// Set gin mode based on debug flag
if !config.Debug {
gin.SetMode(gin.ReleaseMode)
@@ -69,10 +73,18 @@ func NewServer(db *database.DB, config ServerConfig) *Server {
router.Use(cors.New(corsConfig))
}
// Create request handlers
middlewareHandler := handlers.NewMiddlewareHandler(db)
resourceHandler := handlers.NewResourceHandler(db)
configHandler := handlers.NewConfigHandler(db)
// Setup server
server := &Server{
db: db,
router: router,
db: db,
router: router,
middlewareHandler: middlewareHandler,
resourceHandler: resourceHandler,
configHandler: configHandler,
srv: &http.Server{
Addr: ":" + config.Port,
Handler: router,
@@ -102,29 +114,29 @@ func (s *Server) setupRoutes(uiPath string) {
// Middleware routes
middlewares := api.Group("/middlewares")
{
middlewares.GET("", s.getMiddlewares)
middlewares.POST("", s.createMiddleware)
middlewares.GET("/:id", s.getMiddleware)
middlewares.PUT("/:id", s.updateMiddleware)
middlewares.DELETE("/:id", s.deleteMiddleware)
middlewares.GET("", s.middlewareHandler.GetMiddlewares)
middlewares.POST("", s.middlewareHandler.CreateMiddleware)
middlewares.GET("/:id", s.middlewareHandler.GetMiddleware)
middlewares.PUT("/:id", s.middlewareHandler.UpdateMiddleware)
middlewares.DELETE("/:id", s.middlewareHandler.DeleteMiddleware)
}
// Resource routes
resources := api.Group("/resources")
{
resources.GET("", s.getResources)
resources.GET("/:id", s.getResource)
resources.DELETE("/:id", s.deleteResource)
resources.POST("/:id/middlewares", s.assignMiddleware)
resources.POST("/:id/middlewares/bulk", s.assignMultipleMiddlewares)
resources.DELETE("/:id/middlewares/:middlewareId", s.removeMiddleware)
resources.GET("", s.resourceHandler.GetResources)
resources.GET("/:id", s.resourceHandler.GetResource)
resources.DELETE("/:id", s.resourceHandler.DeleteResource)
resources.POST("/:id/middlewares", s.resourceHandler.AssignMiddleware)
resources.POST("/:id/middlewares/bulk", s.resourceHandler.AssignMultipleMiddlewares)
resources.DELETE("/:id/middlewares/:middlewareId", s.resourceHandler.RemoveMiddleware)
// Router configuration routes
resources.PUT("/:id/config/http", s.updateHTTPConfig) // HTTP entrypoints
resources.PUT("/:id/config/tls", s.updateTLSConfig) // TLS certificate domains
resources.PUT("/:id/config/tcp", s.updateTCPConfig) // TCP SNI routing
resources.PUT("/:id/config/headers", s.updateHeadersConfig) // Custom Host headers
resources.PUT("/:id/config/priority", s.updateRouterPriority) // Router priority
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
}
}

98
database/transaction.go Normal file
View File

@@ -0,0 +1,98 @@
package database
import (
"database/sql"
"fmt"
"log"
)
// TxFn represents a function that uses a transaction
type TxFn func(*sql.Tx) error
// WithTransaction wraps a function with a transaction
func (db *DB) WithTransaction(fn TxFn) error {
tx, err := db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if p := recover(); p != nil {
// Ensure rollback on panic
log.Printf("Recovered from panic in transaction: %v", p)
tx.Rollback()
panic(p) // Re-throw panic after rollback
}
}()
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
log.Printf("Warning: Rollback failed: %v (original error: %v)", rbErr, err)
return fmt.Errorf("rollback failed: %v (original error: %w)", rbErr, err)
}
log.Printf("Transaction rolled back due to error: %v", err)
return err
}
if err := tx.Commit(); err != nil {
log.Printf("Error committing transaction: %v", err)
return fmt.Errorf("commit failed: %w", err)
}
return nil
}
// QueryRow executes a query that returns a single row and scans the result into the provided destination
func (db *DB) QueryRowSafe(query string, dest interface{}, args ...interface{}) error {
row := db.QueryRow(query, args...)
if err := row.Scan(dest); err != nil {
if err == sql.ErrNoRows {
return ErrNotFound
}
return fmt.Errorf("scan failed: %w", err)
}
return nil
}
// ExecSafe executes a statement and returns the result summary
func (db *DB) ExecSafe(query string, args ...interface{}) (sql.Result, error) {
result, err := db.Exec(query, args...)
if err != nil {
return nil, fmt.Errorf("exec failed: %w", err)
}
return result, nil
}
// CustomError types for database operations
var (
ErrNotFound = fmt.Errorf("record not found")
ErrDuplicate = fmt.Errorf("duplicate record")
ErrConstraint = fmt.Errorf("constraint violation")
)
// ExecTx executes a statement within a transaction and returns the result
func ExecTx(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) {
result, err := tx.Exec(query, args...)
if err != nil {
return nil, fmt.Errorf("exec in transaction failed: %w", err)
}
return result, nil
}
// GetRowsAffected is a helper to get rows affected from a result
func GetRowsAffected(result sql.Result) (int64, error) {
affected, err := result.RowsAffected()
if err != nil {
return 0, fmt.Errorf("failed to get rows affected: %w", err)
}
return affected, nil
}
// GetLastInsertID is a helper to get last insert ID from a result
func GetLastInsertID(result sql.Result) (int64, error) {
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("failed to get last insert ID: %w", err)
}
return id, nil
}

484
models/middleware_types.go Normal file
View File

@@ -0,0 +1,484 @@
package models
import (
"strings"
)
// MiddlewareProcessor interface for type-specific processing
type MiddlewareProcessor interface {
Process(config map[string]interface{}) map[string]interface{}
}
// Registry of middleware processors
var middlewareProcessors = map[string]MiddlewareProcessor{
"headers": &HeadersProcessor{},
"basicAuth": &AuthProcessor{},
"forwardAuth": &AuthProcessor{},
"digestAuth": &AuthProcessor{},
"redirectRegex": &PathProcessor{},
"redirectScheme": &PathProcessor{},
"replacePath": &PathProcessor{},
"replacePathRegex": &PathProcessor{},
"stripPrefix": &PathProcessor{},
"stripPrefixRegex": &PathProcessor{},
"chain": &ChainProcessor{},
"plugin": &PluginProcessor{},
"rateLimit": &RateLimitProcessor{},
"inFlightReq": &RateLimitProcessor{},
"ipWhiteList": &IPFilterProcessor{},
"ipAllowList": &IPFilterProcessor{},
// Add more middleware types as needed
}
// GetProcessor returns the appropriate processor for a middleware type
func GetProcessor(middlewareType string) MiddlewareProcessor {
if processor, exists := middlewareProcessors[middlewareType]; exists {
return processor
}
return &DefaultProcessor{} // Fallback processor
}
// ProcessMiddlewareConfig processes a middleware configuration based on its type
func ProcessMiddlewareConfig(middlewareType string, config map[string]interface{}) map[string]interface{} {
processor := GetProcessor(middlewareType)
return processor.Process(config)
}
// DefaultProcessor is the fallback processor for middleware types without a specific processor
type DefaultProcessor struct{}
// Process handles general middleware configuration processing
func (p *DefaultProcessor) Process(config map[string]interface{}) map[string]interface{} {
return preserveTraefikValues(config).(map[string]interface{})
}
// HeadersProcessor handles headers middleware specific processing
type HeadersProcessor struct{}
// Process implements special handling for headers middleware
func (p *HeadersProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Special handling for response headers which might contain empty strings
if customResponseHeaders, ok := config["customResponseHeaders"].(map[string]interface{}); ok {
for key, value := range customResponseHeaders {
// Ensure empty strings are preserved exactly
if strValue, ok := value.(string); ok {
customResponseHeaders[key] = strValue
}
}
}
// Special handling for request headers which might contain empty strings
if customRequestHeaders, ok := config["customRequestHeaders"].(map[string]interface{}); ok {
for key, value := range customRequestHeaders {
// Ensure empty strings are preserved exactly
if strValue, ok := value.(string); ok {
customRequestHeaders[key] = strValue
}
}
}
// Process header fields that are often quoted strings
specialStringFields := []string{
"customFrameOptionsValue", "contentSecurityPolicy",
"referrerPolicy", "permissionsPolicy",
}
for _, field := range specialStringFields {
if value, ok := config[field].(string); ok {
// Preserve string exactly, especially if it contains quotes
config[field] = value
}
}
// Process other header configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// AuthProcessor handles authentication middlewares specific processing
type AuthProcessor struct{}
// Process implements special handling for authentication middlewares
func (p *AuthProcessor) Process(config map[string]interface{}) map[string]interface{} {
// ForwardAuth middleware special handling
if address, ok := config["address"].(string); ok {
// Preserve address URL exactly
config["address"] = address
}
// Process trust settings
if trustForward, ok := config["trustForwardHeader"].(bool); ok {
config["trustForwardHeader"] = trustForward
}
// Process headers array
if headers, ok := config["authResponseHeaders"].([]interface{}); ok {
for i, header := range headers {
if headerStr, ok := header.(string); ok {
headers[i] = headerStr
}
}
}
// BasicAuth/DigestAuth middleware special handling
// Preserve exact format of users array
if users, ok := config["users"].([]interface{}); ok {
for i, user := range users {
if userStr, ok := user.(string); ok {
users[i] = userStr
}
}
}
// Process other auth configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// PathProcessor handles path manipulation middlewares specific processing
type PathProcessor struct{}
// Process implements special handling for path manipulation middlewares
func (p *PathProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Special handling for regex patterns - these need exact preservation
if regex, ok := config["regex"].(string); ok {
// Preserve regex pattern exactly
config["regex"] = regex
} else if regexList, ok := config["regex"].([]interface{}); ok {
// Handle regex arrays (like in stripPrefixRegex)
for i, r := range regexList {
if regexStr, ok := r.(string); ok {
regexList[i] = regexStr
}
}
}
// Special handling for replacement patterns
if replacement, ok := config["replacement"].(string); ok {
// Preserve replacement pattern exactly
config["replacement"] = replacement
}
// Special handling for path values
if path, ok := config["path"].(string); ok {
// Preserve path exactly
config["path"] = path
}
// Special handling for prefixes arrays
if prefixes, ok := config["prefixes"].([]interface{}); ok {
for i, prefix := range prefixes {
if prefixStr, ok := prefix.(string); ok {
prefixes[i] = prefixStr
}
}
}
// Special handling for scheme
if scheme, ok := config["scheme"].(string); ok {
// Preserve scheme exactly
config["scheme"] = scheme
}
// Process boolean options
if forceSlash, ok := config["forceSlash"].(bool); ok {
config["forceSlash"] = forceSlash
}
if permanent, ok := config["permanent"].(bool); ok {
config["permanent"] = permanent
}
// Process other path manipulation configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// ChainProcessor handles chain middleware specific processing
type ChainProcessor struct{}
// Process implements special handling for chain middleware
func (p *ChainProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Process middlewares array
if middlewares, ok := config["middlewares"].([]interface{}); ok {
for i, middleware := range middlewares {
if middlewareStr, ok := middleware.(string); ok {
// If this is not already a fully qualified middleware reference
if !strings.Contains(middlewareStr, "@") {
// Assume it's from our file provider
middlewares[i] = middlewareStr
}
}
}
}
// Process other chain configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// PluginProcessor handles plugin middleware specific processing
type PluginProcessor struct{}
// Process implements special handling for plugin middleware
func (p *PluginProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Process plugins (including CrowdSec)
for _, pluginCfg := range config {
if pluginConfig, ok := pluginCfg.(map[string]interface{}); ok {
// Process special fields in plugin configurations
// Process API keys and secrets - must be preserved exactly
keyFields := []string{
"crowdsecLapiKey", "apiKey", "token", "secret", "password",
"key", "accessKey", "secretKey", "captchaSiteKey", "captchaSecretKey",
}
for _, field := range keyFields {
if val, exists := pluginConfig[field]; exists {
if strVal, ok := val.(string); ok {
// Ensure keys are preserved exactly as-is
pluginConfig[field] = strVal
}
}
}
// Process boolean options
boolFields := []string{
"enabled", "failureBlock", "unreachableBlock", "insecureVerify",
"allowLocalRequests", "logLocalRequests", "logAllowedRequests",
"logApiRequests", "silentStartUp", "forceMonthlyUpdate",
"allowUnknownCountries", "blackListMode", "addCountryHeader",
}
for _, field := range boolFields {
for configKey, val := range pluginConfig {
if strings.Contains(configKey, field) {
if boolVal, ok := val.(bool); ok {
pluginConfig[configKey] = boolVal
}
}
}
}
// Process arrays
arrayFields := []string{
"forwardedHeadersTrustedIPs", "clientTrustedIPs", "countries",
}
for _, field := range arrayFields {
for configKey, val := range pluginConfig {
if strings.Contains(configKey, field) {
if arrayVal, ok := val.([]interface{}); ok {
for i, item := range arrayVal {
if strItem, ok := item.(string); ok {
arrayVal[i] = strItem
}
}
}
}
}
}
// Process remaining fields with general processor
preserveTraefikValues(pluginConfig)
}
}
// Process the entire config with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// RateLimitProcessor handles rate limiting middlewares specific processing
type RateLimitProcessor struct{}
// Process implements special handling for rate limiting middlewares
func (p *RateLimitProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Process numeric values
if average, ok := config["average"].(float64); ok {
// Convert to int if it's a whole number
if average == float64(int(average)) {
config["average"] = int(average)
} else {
config["average"] = average
}
}
if burst, ok := config["burst"].(float64); ok {
// Convert to int if it's a whole number
if burst == float64(int(burst)) {
config["burst"] = int(burst)
} else {
config["burst"] = burst
}
}
if amount, ok := config["amount"].(float64); ok {
// Convert to int if it's a whole number
if amount == float64(int(amount)) {
config["amount"] = int(amount)
} else {
config["amount"] = amount
}
}
// Process sourceCriterion for inFlightReq
if sourceCriterion, ok := config["sourceCriterion"].(map[string]interface{}); ok {
// Process IP strategy
if ipStrategy, ok := sourceCriterion["ipStrategy"].(map[string]interface{}); ok {
// Process depth
if depth, ok := ipStrategy["depth"].(float64); ok {
ipStrategy["depth"] = int(depth)
}
// Process excluded IPs
if excludedIPs, ok := ipStrategy["excludedIPs"].([]interface{}); ok {
for i, ip := range excludedIPs {
if ipStr, ok := ip.(string); ok {
excludedIPs[i] = ipStr
}
}
}
}
// Process requestHost boolean
if requestHost, ok := sourceCriterion["requestHost"].(bool); ok {
sourceCriterion["requestHost"] = requestHost
}
}
// Process other rate limiting configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// IPFilterProcessor handles IP filtering middlewares specific processing
type IPFilterProcessor struct{}
// Process implements special handling for IP filtering middlewares
func (p *IPFilterProcessor) Process(config map[string]interface{}) map[string]interface{} {
// Process sourceRange IPs
if sourceRange, ok := config["sourceRange"].([]interface{}); ok {
for i, range_ := range sourceRange {
if rangeStr, ok := range_.(string); ok {
// Preserve IP CIDR notation exactly
sourceRange[i] = rangeStr
}
}
}
// Process other IP filtering configuration values with general processor
return preserveTraefikValues(config).(map[string]interface{})
}
// preserveTraefikValues ensures all values in Traefik configurations are properly handled
// This handles special cases in different middleware types and ensures precise value preservation
func preserveTraefikValues(data interface{}) interface{} {
if data == nil {
return nil
}
switch v := data.(type) {
case map[string]interface{}:
// Process each key-value pair in the map
for key, val := range v {
// Process values based on key names that might need special handling
switch {
// URL or path related fields
case key == "path" || key == "url" || key == "address" || strings.HasSuffix(key, "Path"):
// Ensure path strings keep their exact format
if strVal, ok := val.(string); ok && strVal != "" {
// Keep exact string formatting
v[key] = strVal
} else {
v[key] = preserveTraefikValues(val)
}
// Regex and replacement patterns
case key == "regex" || key == "replacement" || strings.HasSuffix(key, "Regex"):
// Ensure regex patterns are preserved exactly
if strVal, ok := val.(string); ok && strVal != "" {
// Keep exact string formatting with special characters
v[key] = strVal
} else {
v[key] = preserveTraefikValues(val)
}
// API keys and security tokens
case key == "key" || key == "token" || key == "secret" ||
strings.Contains(key, "Key") || strings.Contains(key, "Token") ||
strings.Contains(key, "Secret") || strings.Contains(key, "Password"):
// Ensure API keys and tokens are preserved exactly
if strVal, ok := val.(string); ok {
// Always preserve keys/tokens exactly as-is, even if empty
v[key] = strVal
} else {
v[key] = preserveTraefikValues(val)
}
// Empty header values (common in security headers middleware)
case key == "Server" || key == "X-Powered-By" || strings.HasPrefix(key, "X-"):
// Empty string values are often used to remove headers
if strVal, ok := val.(string); ok {
// Preserve empty strings exactly
v[key] = strVal
} else {
v[key] = preserveTraefikValues(val)
}
// IP addresses and networks
case key == "ip" || key == "clientIP" || strings.Contains(key, "IP") ||
key == "sourceRange" || key == "excludedIPs":
// IP addresses often need exact formatting
v[key] = preserveTraefikValues(val)
// Boolean flags that control behavior
case strings.HasPrefix(key, "is") || strings.HasPrefix(key, "has") ||
strings.HasPrefix(key, "enable") || strings.HasSuffix(key, "enabled") ||
strings.HasSuffix(key, "Enabled") || key == "permanent" || key == "forceSlash":
// Ensure boolean values are preserved as actual booleans
if boolVal, ok := val.(bool); ok {
v[key] = boolVal
} else if strVal, ok := val.(string); ok {
// Convert string "true"/"false" to actual boolean if needed
if strVal == "true" {
v[key] = true
} else if strVal == "false" {
v[key] = false
} else {
v[key] = strVal // Keep as is if not a boolean string
}
} else {
v[key] = preserveTraefikValues(val)
}
// Integer values like timeouts, ports, limits
case key == "amount" || key == "burst" || key == "port" ||
strings.HasSuffix(key, "Seconds") || strings.HasSuffix(key, "Limit") ||
strings.HasSuffix(key, "Timeout") || strings.HasSuffix(key, "Size") ||
key == "depth" || key == "priority" || key == "statusCode" ||
key == "attempts" || key == "responseCode":
// Handle float64 to int conversion for whole numbers, common in JSON unmarshaling
if f, ok := val.(float64); ok && f == float64(int(f)) {
v[key] = int(f)
} else {
v[key] = preserveTraefikValues(val)
}
// Default handling for other keys
default:
v[key] = preserveTraefikValues(val)
}
}
return v
case []interface{}:
// Process each element in the array
for i, item := range v {
v[i] = preserveTraefikValues(item)
}
return v
case string, int, float64, bool:
// Preserve primitive types as they are
return v
default:
// For any other type, return as is
return v
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
import React from 'react';
// Moon icon SVG component for dark mode
const MoonIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="dark-mode-icon"
>
<path
fillRule="evenodd"
d="M9.528 1.718a.75.75 0 01.162.819A8.97 8.97 0 009 6a9 9 0 009 9 8.97 8.97 0 003.463-.69.75.75 0 01.981.98 10.503 10.503 0 01-9.694 6.46c-5.799 0-10.5-4.701-10.5-10.5 0-4.368 2.667-8.112 6.46-9.694a.75.75 0 01.818.162z"
clipRule="evenodd"
/>
</svg>
);
// Sun icon SVG component for light mode
const SunIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="light-mode-icon"
>
<path
d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z"
/>
</svg>
);
/**
* Dark mode toggle component
*
* @param {Object} props
* @param {boolean} props.isDark - Current dark mode state
* @param {function} props.setIsDark - Function to toggle dark mode
* @returns {JSX.Element}
*/
const DarkModeToggle = ({ isDark, setIsDark }) => {
// Toggle dark mode
const toggleDarkMode = () => {
if (isDark) {
document.documentElement.classList.remove('dark-mode');
localStorage.setItem('theme', 'light');
setIsDark(false);
} else {
document.documentElement.classList.add('dark-mode');
localStorage.setItem('theme', 'dark');
setIsDark(true);
}
};
return (
<button
onClick={toggleDarkMode}
className="dark-mode-toggle ml-4"
aria-label={isDark ? "Switch to light mode" : "Switch to dark mode"}
title={isDark ? "Switch to light mode" : "Switch to dark mode"}
>
{isDark ? <SunIcon /> : <MoonIcon />}
</button>
);
};
export default DarkModeToggle;

View File

@@ -0,0 +1,74 @@
import React from 'react';
/**
* Error message component for displaying errors
*
* @param {Object} props
* @param {string} props.message - Error message to display
* @param {string} props.details - Optional error details
* @param {function} props.onRetry - Optional retry function
* @param {function} props.onDismiss - Optional dismiss function
* @returns {JSX.Element}
*/
const ErrorMessage = ({
message,
details = null,
onRetry = null,
onDismiss = null
}) => {
return (
<div className="bg-red-100 text-red-700 p-6 rounded-lg border border-red-300 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0 pt-0.5">
<svg
className="h-5 w-5 text-red-500"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-md font-medium text-red-800">{message}</h3>
{details && (
<div className="mt-2 text-sm text-red-700">
<p>{details}</p>
</div>
)}
{(onRetry || onDismiss) && (
<div className="mt-4 flex">
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mr-3 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
>
Retry
</button>
)}
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Dismiss
</button>
)}
</div>
)}
</div>
</div>
</div>
);
};
export default ErrorMessage;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import DarkModeToggle from './DarkModeToggle';
/**
* Main navigation header component
*
* @param {Object} props
* @param {string} props.currentPage - Current active page
* @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
* @returns {JSX.Element}
*/
const Header = ({ currentPage, navigateTo, isDarkMode, setIsDarkMode }) => {
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>
<div className="flex items-center">
<div className="space-x-4">
<button
onClick={() => navigateTo('dashboard')}
className={`px-3 py-2 rounded hover:bg-gray-100 ${
currentPage === 'dashboard' ? 'bg-gray-100' : ''
}`}
>
Dashboard
</button>
<button
onClick={() => navigateTo('resources')}
className={`px-3 py-2 rounded hover:bg-gray-100 ${
currentPage === 'resources' || currentPage === 'resource-detail'
? 'bg-gray-100'
: ''
}`}
>
Resources
</button>
<button
onClick={() => navigateTo('middlewares')}
className={`px-3 py-2 rounded hover:bg-gray-100 ${
currentPage === 'middlewares' || currentPage === 'middleware-form'
? 'bg-gray-100'
: ''
}`}
>
Middlewares
</button>
</div>
<DarkModeToggle isDark={isDarkMode} setIsDark={setIsDarkMode} />
</div>
</div>
</div>
</nav>
);
};
export default Header;

View File

@@ -0,0 +1,32 @@
import React from 'react';
/**
* Loading spinner component with optional message
*
* @param {Object} props
* @param {string} props.message - Optional loading message
* @param {string} props.size - Size of the spinner: "sm", "md", "lg"
* @returns {JSX.Element}
*/
const LoadingSpinner = ({ message = 'Loading...', size = 'md' }) => {
// Determine spinner size based on prop
const spinnerSizes = {
sm: 'w-6 h-6 border-2',
md: 'w-12 h-12 border-3',
lg: 'w-16 h-16 border-4',
};
const spinnerSize = spinnerSizes[size] || spinnerSizes.md;
return (
<div className="flex flex-col items-center justify-center p-6">
<div className={`${spinnerSize} border-blue-500 border-t-transparent rounded-full animate-spin mb-4`}></div>
{message && (
<p className="text-gray-600">{message}</p>
)}
</div>
);
};
export default LoadingSpinner;

View File

@@ -0,0 +1,9 @@
/**
* Common components export file
* This file exports all common components to simplify imports
*/
export { default as Header } from './Header';
export { default as LoadingSpinner } from './LoadingSpinner';
export { default as ErrorMessage } from './ErrorMessage';
export { default as DarkModeToggle } from './DarkModeToggle';

View File

@@ -0,0 +1,222 @@
import React, { useEffect } from 'react';
import { useResources } from '../../contexts/ResourceContext';
import { useMiddlewares } from '../../contexts/MiddlewareContext';
import { LoadingSpinner, ErrorMessage } from '../common';
import { MiddlewareUtils } from '../../services/api';
import StatCard from './StatCard';
import ResourceSummary from './ResourceSummary';
/**
* Dashboard component that shows system overview
*
* @param {Object} props
* @param {function} props.navigateTo - Navigation function
* @returns {JSX.Element}
*/
const Dashboard = ({ navigateTo }) => {
const {
resources,
loading: resourcesLoading,
error: resourcesError,
fetchResources
} = useResources();
const {
middlewares,
loading: middlewaresLoading,
error: middlewaresError,
fetchMiddlewares
} = useMiddlewares();
// Refresh data when the dashboard is mounted
useEffect(() => {
fetchResources();
fetchMiddlewares();
}, [fetchResources, fetchMiddlewares]);
// Show loading state while fetching data
if (resourcesLoading || middlewaresLoading) {
return (
<div className="flex flex-col items-center justify-center p-12">
<LoadingSpinner
size="lg"
message="Initializing Middleware Manager"
/>
</div>
);
}
// Show error state if there was an error
if (resourcesError || middlewaresError) {
return (
<ErrorMessage
message="Error Loading Dashboard"
details={resourcesError || middlewaresError}
onRetry={() => {
fetchResources();
fetchMiddlewares();
}}
/>
);
}
// Calculate statistics for dashboard
const protectedResources = resources.filter(
(r) => r.status !== 'disabled' && r.middlewares && r.middlewares.length > 0
).length;
const activeResources = resources.filter(
(r) => r.status !== 'disabled'
).length;
const disabledResources = resources.filter(
(r) => r.status === 'disabled'
).length;
const unprotectedResources = activeResources - protectedResources;
return (
<div>
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
{/* Stats Section */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<StatCard
title="Resources"
value={activeResources}
subtitle={disabledResources > 0 ? `${disabledResources} disabled resources` : null}
/>
<StatCard
title="Middlewares"
value={middlewares.length}
/>
<StatCard
title="Protected Resources"
value={`${protectedResources} / ${activeResources}`}
status={
protectedResources === 0 ? 'danger' :
protectedResources < activeResources ? 'warning' : 'success'
}
/>
</div>
{/* Recent Resources Section */}
<div className="bg-white p-6 rounded-lg shadow mb-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Recent Resources</h2>
<button
onClick={() => navigateTo('resources')}
className="text-blue-600 hover:underline"
>
View All
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full">
<thead>
<tr className="bg-gray-50">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Middlewares
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{resources.slice(0, 5).map((resource) => (
<ResourceSummary
key={resource.id}
resource={resource}
onView={() => navigateTo('resource-detail', resource.id)}
onDelete={fetchResources}
/>
))}
{resources.length === 0 && (
<tr>
<td
colSpan="4"
className="px-6 py-4 text-center text-gray-500"
>
No resources found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Warning for Unprotected Resources */}
{unprotectedResources > 0 && (
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-8">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-yellow-700">
You have {unprotectedResources} active resources that are not
protected with any middleware.
</p>
</div>
</div>
</div>
)}
{/* Warning for Disabled Resources */}
{disabledResources > 0 && (
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 mb-8">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-blue-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-blue-700">
You have {disabledResources} disabled resources that were removed
from Pangolin.{' '}
<button
className="underline"
onClick={() => navigateTo('resources')}
>
View all resources
</button>{' '}
to delete them.
</p>
</div>
</div>
</div>
)}
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { useResources } from '../../contexts/ResourceContext';
import { MiddlewareUtils } from '../../services/api';
/**
* ResourceSummary component for displaying a resource row in the dashboard
*
* @param {Object} props
* @param {Object} props.resource - Resource data
* @param {Function} props.onView - Function to handle viewing the resource
* @param {Function} props.onDelete - Function to call after successful deletion
* @returns {JSX.Element}
*/
const ResourceSummary = ({ resource, onView, onDelete }) => {
const { deleteResource } = useResources();
// Parse middlewares from the resource
const middlewaresList = MiddlewareUtils.parseMiddlewares(resource.middlewares);
const isProtected = middlewaresList.length > 0;
const isDisabled = resource.status === 'disabled';
/**
* Handle resource deletion with confirmation
*/
const handleDelete = async () => {
if (
window.confirm(
`Are you sure you want to delete the resource "${resource.host}"? This cannot be undone.`
)
) {
const success = await deleteResource(resource.id);
if (success) {
// Notify parent component about the deletion
if (onDelete) onDelete();
}
}
};
return (
<tr className={isDisabled ? 'bg-gray-100' : ''}>
<td className="px-6 py-4 whitespace-nowrap">
{resource.host}
{isDisabled && (
<span className="ml-2 px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">
Removed from Pangolin
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
isDisabled
? 'bg-gray-100 text-gray-800'
: isProtected
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{isDisabled
? 'Disabled'
: isProtected
? 'Protected'
: 'Not Protected'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{middlewaresList.length > 0
? middlewaresList.length
: 'None'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={onView}
className="text-blue-600 hover:text-blue-900 mr-3"
>
{isDisabled ? 'View' : 'Manage'}
</button>
{isDisabled && (
<button
onClick={handleDelete}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
)}
</td>
</tr>
);
};
export default ResourceSummary;

View File

@@ -0,0 +1,46 @@
import React from 'react';
/**
* Stat card component for displaying dashboard statistics
*
* @param {Object} props
* @param {string} props.title - Card title
* @param {string|number} props.value - Main statistic value
* @param {string} [props.subtitle] - Optional subtitle
* @param {string} [props.status] - Optional status (success, warning, danger)
* @returns {JSX.Element}
*/
const StatCard = ({ title, value, subtitle = null, status = null }) => {
// Determine background color based on status
let bgColor = '';
if (status) {
switch (status) {
case 'success':
bgColor = 'bg-green-50 border-green-500';
break;
case 'warning':
bgColor = 'bg-yellow-50 border-yellow-500';
break;
case 'danger':
bgColor = 'bg-red-50 border-red-500';
break;
default:
bgColor = '';
}
}
return (
<div className={`bg-white p-6 rounded-lg shadow ${bgColor ? `${bgColor} border-l-4` : ''}`}>
<h3 className="text-lg font-semibold mb-2">{title}</h3>
<p className="text-3xl font-bold">{value}</p>
{subtitle && (
<p className="text-sm text-gray-500 mt-1">
{subtitle}
</p>
)}
</div>
);
};
export default StatCard;

View File

@@ -0,0 +1,302 @@
import React, { useState, useEffect } from 'react';
import { useMiddlewares } from '../../contexts/MiddlewareContext';
import { LoadingSpinner, ErrorMessage } from '../common';
const MiddlewareForm = ({ id, isEditing, navigateTo }) => {
const {
middlewares,
selectedMiddleware,
loading,
error,
fetchMiddleware,
createMiddleware,
updateMiddleware,
getConfigTemplate
} = useMiddlewares();
// Form state
const [middleware, setMiddleware] = useState({
name: '',
type: 'basicAuth',
config: {}
});
const [configText, setConfigText] = useState('{\n "users": [\n "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"\n ]\n}');
const [formError, setFormError] = useState(null);
// Available middleware types
const middlewareTypes = [
{ value: 'basicAuth', label: 'Basic Authentication' },
{ value: 'digestAuth', label: 'Digest Authentication' },
{ value: 'forwardAuth', label: 'Forward Authentication' },
{ value: 'ipWhiteList', label: 'IP Whitelist' },
{ value: 'ipAllowList', label: 'IP Allow List' },
{ value: 'rateLimit', label: 'Rate Limiting' },
{ value: 'headers', label: 'HTTP Headers' },
{ value: 'stripPrefix', label: 'Strip Prefix' },
{ value: 'stripPrefixRegex', label: 'Strip Prefix Regex' },
{ value: 'addPrefix', label: 'Add Prefix' },
{ value: 'redirectRegex', label: 'Redirect Regex' },
{ value: 'redirectScheme', label: 'Redirect Scheme' },
{ value: 'replacePath', label: 'Replace Path' },
{ value: 'replacePathRegex', label: 'Replace Path Regex' },
{ value: 'chain', label: 'Middleware Chain' },
{ value: 'plugin', label: 'Traefik Plugin' },
{ value: 'buffering', label: 'Buffering' },
{ value: 'circuitBreaker', label: 'Circuit Breaker' },
{ value: 'compress', label: 'Compression' },
{ value: 'contentType', label: 'Content Type' },
{ value: 'errors', label: 'Error Pages' },
{ value: 'grpcWeb', label: 'gRPC Web' },
{ value: 'inFlightReq', label: 'In-Flight Request Limiter' },
{ value: 'passTLSClientCert', label: 'Pass TLS Client Certificate' },
{ value: 'retry', label: 'Retry' }
];
// Fetch middleware details if editing
useEffect(() => {
if (isEditing && id) {
fetchMiddleware(id);
}
}, [id, isEditing, fetchMiddleware]);
// Update form state when middleware data is loaded
useEffect(() => {
if (isEditing && selectedMiddleware) {
// Format config as pretty JSON string
const configJson = typeof selectedMiddleware.config === 'string'
? selectedMiddleware.config
: JSON.stringify(selectedMiddleware.config, null, 2);
setMiddleware({
name: selectedMiddleware.name,
type: selectedMiddleware.type,
config: selectedMiddleware.config
});
setConfigText(configJson);
}
}, [isEditing, selectedMiddleware]);
// Update config template when type changes
const handleTypeChange = (e) => {
const newType = e.target.value;
setMiddleware({ ...middleware, type: newType });
// Get template for this middleware type
const template = getConfigTemplate(newType);
setConfigText(template);
};
// Handle middleware selection for chain type
const handleMiddlewareSelection = (e) => {
const options = e.target.options;
const selected = Array.from(options)
.filter(option => option.selected)
.map(option => option.value);
// Update the config text to reflect the selected middlewares
const configObj = { middlewares: selected };
setConfigText(JSON.stringify(configObj, null, 2));
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
setFormError(null);
try {
// Parse config JSON
let configObj;
if (middleware.type === 'chain') {
// For chain type, extract from configText
try {
configObj = JSON.parse(configText);
} catch (err) {
setFormError('Invalid JSON configuration');
return;
}
} else {
try {
configObj = JSON.parse(configText);
} catch (err) {
setFormError('Invalid JSON configuration');
return;
}
}
const middlewareData = {
name: middleware.name,
type: middleware.type,
config: configObj
};
if (isEditing) {
await updateMiddleware(id, middlewareData);
} else {
await createMiddleware(middlewareData);
}
navigateTo('middlewares');
} catch (err) {
setFormError(`Failed to ${isEditing ? 'update' : 'create'} middleware: ${err.message}`);
}
};
if (loading && isEditing) {
return <LoadingSpinner message="Loading middleware..." />;
}
return (
<div>
<div className="mb-6 flex items-center">
<button
onClick={() => navigateTo('middlewares')}
className="mr-4 px-3 py-1 bg-gray-200 rounded hover:bg-gray-300"
>
Back
</button>
<h1 className="text-2xl font-bold">
{isEditing ? 'Edit Middleware' : 'Create Middleware'}
</h1>
</div>
{error && (
<ErrorMessage
message={error}
onDismiss={() => setFormError(null)}
/>
)}
{formError && (
<ErrorMessage
message={formError}
onDismiss={() => setFormError(null)}
/>
)}
<div className="bg-white p-6 rounded-lg shadow">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Middleware Name
</label>
<input
type="text"
value={middleware.name}
onChange={(e) => setMiddleware({ ...middleware, name: e.target.value })}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="e.g., production-authentication"
required
/>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Middleware Type
</label>
<select
value={middleware.type}
onChange={handleTypeChange}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
required
disabled={isEditing}
>
{middlewareTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
{isEditing && (
<p className="text-xs text-gray-500 mt-1">
Middleware type cannot be changed after creation
</p>
)}
</div>
{middleware.type === 'chain' ? (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Select Middlewares for Chain
</label>
{middlewares.length > 0 ? (
<>
<select
multiple
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)}
>
{middlewares
.filter(m => m.id !== id) // Filter out current middleware if editing
.map((mw) => (
<option key={mw.id} value={mw.id}>
{mw.name} ({mw.type})
</option>
))}
</select>
<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>
</>
) : (
<div className="p-3 bg-blue-50 border border-blue-200 rounded text-blue-700">
<p className="mb-2">You need to create other middlewares first before creating a chain.</p>
<button
type="button"
onClick={() => navigateTo('middleware-form')}
className="text-blue-600 hover:underline"
>
Create a new middleware
</button>
</div>
)}
</div>
) : (
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Configuration (JSON)
</label>
<textarea
value={configText}
onChange={(e) => setConfigText(e.target.value)}
className="w-full px-3 py-2 border font-mono h-64 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter JSON configuration"
required
/>
<div className="text-xs text-gray-500 mt-1">
<p>Configuration must be valid JSON for the selected middleware type</p>
{middleware.type === 'headers' && (
<p className="mt-1 text-amber-600 font-medium">
Special note for Headers middleware: Use empty strings ("") to remove headers.
Example: <code className="bg-gray-100 px-1 rounded">{'{"Server": ""}'}</code>
</p>
)}
</div>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => navigateTo('middlewares')}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
disabled={loading || (middleware.type === 'chain' && !configText.includes('"middlewares"'))}
>
{loading ? 'Saving...' : isEditing ? 'Update Middleware' : 'Create Middleware'}
</button>
</div>
</form>
</div>
</div>
);
};
export default MiddlewareForm;

View File

@@ -0,0 +1,190 @@
import React, { useEffect, useState } from 'react';
import { useMiddlewares } from '../../contexts/MiddlewareContext';
import { LoadingSpinner, ErrorMessage } from '../common';
const MiddlewaresList = ({ navigateTo }) => {
const {
middlewares,
loading,
error,
fetchMiddlewares,
deleteMiddleware
} = useMiddlewares();
const [searchTerm, setSearchTerm] = useState('');
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [middlewareToDelete, setMiddlewareToDelete] = useState(null);
useEffect(() => {
fetchMiddlewares();
}, [fetchMiddlewares]);
const confirmDelete = (middleware) => {
setMiddlewareToDelete(middleware);
setShowDeleteModal(true);
};
const handleDeleteMiddleware = async () => {
if (!middlewareToDelete) return;
try {
await deleteMiddleware(middlewareToDelete.id);
setShowDeleteModal(false);
setMiddlewareToDelete(null);
} catch (err) {
alert('Failed to delete middleware');
console.error('Delete middleware error:', err);
}
};
const cancelDelete = () => {
setShowDeleteModal(false);
setMiddlewareToDelete(null);
};
const filteredMiddlewares = middlewares.filter(
(middleware) =>
middleware.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
middleware.type.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading && !middlewares.length) {
return <LoadingSpinner message="Loading middlewares..." />;
}
if (error) {
return (
<ErrorMessage
message="Failed to load middlewares"
details={error}
onRetry={fetchMiddlewares}
/>
);
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Middlewares</h1>
<div className="mb-6 flex justify-between">
<div className="relative w-64">
<input
type="text"
placeholder="Search middlewares..."
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="space-x-3">
<button
onClick={fetchMiddlewares}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
disabled={loading}
>
Refresh
</button>
<button
onClick={() => navigateTo('middleware-form')}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Create Middleware
</button>
</div>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredMiddlewares.map((middleware) => (
<tr key={middleware.id}>
<td className="px-6 py-4 whitespace-nowrap">
{middleware.name}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{middleware.type}
{middleware.type === 'chain' && " (Middleware Chain)"}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex justify-end space-x-2">
<button
onClick={() => navigateTo('middleware-form', middleware.id)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
Edit
</button>
<button
onClick={() => confirmDelete(middleware)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</td>
</tr>
))}
{filteredMiddlewares.length === 0 && (
<tr>
<td
colSpan="3"
className="px-6 py-4 text-center text-gray-500"
>
No middlewares found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Delete Confirmation Modal */}
{showDeleteModal && middlewareToDelete && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="px-6 py-4 border-b">
<h3 className="text-lg font-semibold text-red-600">Confirm Deletion</h3>
</div>
<div className="px-6 py-4">
<p className="mb-4">
Are you sure you want to delete the middleware "{middlewareToDelete.name}"?
</p>
<p className="text-sm text-gray-500 mb-4">
This action cannot be undone and may affect any resources currently using this middleware.
</p>
<div className="flex justify-end space-x-3">
<button
onClick={cancelDelete}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
onClick={handleDeleteMiddleware}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default MiddlewaresList;

View File

@@ -0,0 +1,644 @@
import React, { useEffect, useState } from 'react';
import { useResources } from '../../contexts/ResourceContext';
import { useMiddlewares } from '../../contexts/MiddlewareContext';
import { LoadingSpinner, ErrorMessage } from '../common';
import HTTPConfigModal from './config/HTTPConfigModal';
import TLSConfigModal from './config/TLSConfigModal';
import TCPConfigModal from './config/TCPConfigModal';
import HeadersConfigModal from './config/HeadersConfigModal';
import { MiddlewareUtils } from '../../services/api';
const ResourceDetail = ({ id, navigateTo }) => {
const {
selectedResource,
loading: resourceLoading,
error: resourceError,
fetchResource,
assignMiddleware,
assignMultipleMiddlewares,
removeMiddleware,
updateResourceConfig,
deleteResource
} = useResources();
const {
middlewares,
loading: middlewaresLoading,
error: middlewaresError,
fetchMiddlewares,
formatMiddlewareDisplay
} = useMiddlewares();
// UI state
const [showModal, setShowModal] = useState(false);
const [modalType, setModalType] = useState(null);
const [selectedMiddlewares, setSelectedMiddlewares] = useState([]);
const [priority, setPriority] = useState(100);
const [routerPriority, setRouterPriority] = useState(100);
// Configuration states
const [entrypoints, setEntrypoints] = useState('');
const [tlsDomains, setTLSDomains] = useState('');
const [tcpEnabled, setTCPEnabled] = useState(false);
const [tcpEntrypoints, setTCPEntrypoints] = useState('');
const [tcpSNIRule, setTCPSNIRule] = useState('');
const [customHeaders, setCustomHeaders] = useState({});
const [headerKey, setHeaderKey] = useState('');
const [headerValue, setHeaderValue] = useState('');
// Load resource and middlewares data
useEffect(() => {
fetchResource(id);
fetchMiddlewares();
}, [id, fetchResource, fetchMiddlewares]);
// Update local state when resource data is loaded
useEffect(() => {
if (selectedResource) {
setEntrypoints(selectedResource.entrypoints || 'websecure');
setTLSDomains(selectedResource.tls_domains || '');
setTCPEnabled(selectedResource.tcp_enabled || false);
setTCPEntrypoints(selectedResource.tcp_entrypoints || 'tcp');
setTCPSNIRule(selectedResource.tcp_sni_rule || '');
setRouterPriority(selectedResource.router_priority || 100);
// Parse custom headers
if (selectedResource.custom_headers) {
try {
const headers = JSON.parse(selectedResource.custom_headers);
setCustomHeaders(headers);
} catch (e) {
console.error("Error parsing custom headers:", e);
setCustomHeaders({});
}
} else {
setCustomHeaders({});
}
}
}, [selectedResource]);
// Handle loading state
const loading = resourceLoading || middlewaresLoading;
if (loading && !selectedResource) {
return <LoadingSpinner message="Loading resource details..." />;
}
// Handle error state
const error = resourceError || middlewaresError;
if (error) {
return (
<ErrorMessage
message="Failed to load resource details"
details={error}
onRetry={() => {
fetchResource(id);
fetchMiddlewares();
}}
/>
);
}
if (!selectedResource) {
return (
<ErrorMessage
message="Resource not found"
onRetry={() => navigateTo('resources')}
/>
);
}
// Calculate list of assigned middlewares
const assignedMiddlewares = MiddlewareUtils.parseMiddlewares(selectedResource.middlewares);
// Filter to get available middlewares (not already assigned)
const assignedIds = assignedMiddlewares.map(m => m.id);
const availableMiddlewares = middlewares.filter(m => !assignedIds.includes(m.id));
// Determine if resource is disabled
const isDisabled = selectedResource.status === 'disabled';
// Open configuration modal
const openConfigModal = (type) => {
setModalType(type);
setShowModal(true);
};
// Handle middleware selection
const handleMiddlewareSelection = (e) => {
const options = e.target.options;
const selected = Array.from(options)
.filter((option) => option.selected)
.map((option) => option.value);
setSelectedMiddlewares(selected);
};
// Handle assigning multiple middlewares
const handleAssignMiddleware = async (e) => {
e.preventDefault();
if (selectedMiddlewares.length === 0) {
alert('Please select at least one middleware');
return;
}
const middlewaresToAdd = selectedMiddlewares.map(middlewareId => ({
middleware_id: middlewareId,
priority: parseInt(priority, 10)
}));
await assignMultipleMiddlewares(id, middlewaresToAdd);
setShowModal(false);
setSelectedMiddlewares([]);
setPriority(100);
};
// Handle middleware removal
const handleRemoveMiddleware = async (middlewareId) => {
if (!window.confirm('Are you sure you want to remove this middleware?'))
return;
await removeMiddleware(id, middlewareId);
};
// Handle router priority update
const handleUpdateRouterPriority = async () => {
await updateResourceConfig(id, 'priority', { router_priority: routerPriority });
};
// Handle resource deletion
const handleDeleteResource = async () => {
if (window.confirm(`Are you sure you want to delete the resource "${selectedResource.host}"? This cannot be undone.`)) {
const success = await deleteResource(id);
if (success) {
navigateTo('resources');
}
}
};
// Add a custom header
const addHeader = () => {
if (!headerKey.trim()) {
alert('Header key cannot be empty');
return;
}
setCustomHeaders({
...customHeaders,
[headerKey]: headerValue
});
setHeaderKey('');
setHeaderValue('');
};
// Remove a custom header
const removeHeader = (key) => {
const newHeaders = {...customHeaders};
delete newHeaders[key];
setCustomHeaders(newHeaders);
};
// Render the appropriate config modal based on type
const renderModal = () => {
if (!showModal) return null;
switch (modalType) {
case 'http':
return (
<HTTPConfigModal
entrypoints={entrypoints}
setEntrypoints={setEntrypoints}
onSave={() => updateResourceConfig(id, 'http', { entrypoints })}
onClose={() => setShowModal(false)}
/>
);
case 'tls':
return (
<TLSConfigModal
tlsDomains={tlsDomains}
setTLSDomains={setTLSDomains}
onSave={() => updateResourceConfig(id, 'tls', { tls_domains: tlsDomains })}
onClose={() => setShowModal(false)}
/>
);
case 'tcp':
return (
<TCPConfigModal
tcpEnabled={tcpEnabled}
setTCPEnabled={setTCPEnabled}
tcpEntrypoints={tcpEntrypoints}
setTCPEntrypoints={setTCPEntrypoints}
tcpSNIRule={tcpSNIRule}
setTCPSNIRule={setTCPSNIRule}
resourceHost={selectedResource.host}
onSave={() => updateResourceConfig(id, 'tcp', {
tcp_enabled: tcpEnabled,
tcp_entrypoints: tcpEntrypoints,
tcp_sni_rule: tcpSNIRule
})}
onClose={() => setShowModal(false)}
/>
);
case 'headers':
return (
<HeadersConfigModal
customHeaders={customHeaders}
setCustomHeaders={setCustomHeaders}
headerKey={headerKey}
setHeaderKey={setHeaderKey}
headerValue={headerValue}
setHeaderValue={setHeaderValue}
addHeader={addHeader}
removeHeader={removeHeader}
onSave={() => updateResourceConfig(id, 'headers', { custom_headers: customHeaders })}
onClose={() => setShowModal(false)}
/>
);
case 'middlewares':
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h3 className="text-lg font-semibold">
Add Middlewares to {selectedResource.host}
</h3>
<button
onClick={() => setShowModal(false)}
className="text-gray-500 hover:text-gray-700"
>
×
</button>
</div>
<div className="px-6 py-4">
{availableMiddlewares.length === 0 ? (
<div className="text-center py-4 text-gray-500">
<p>All middlewares have been assigned to this resource.</p>
<button
onClick={() => navigateTo('middleware-form')}
className="mt-2 inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Create New Middleware
</button>
</div>
) : (
<form onSubmit={handleAssignMiddleware}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Select Middlewares
</label>
<select
multiple
value={selectedMiddlewares}
onChange={handleMiddlewareSelection}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
size={5}
>
{availableMiddlewares.map((middleware) => (
<option key={middleware.id} value={middleware.id}>
{middleware.name} ({middleware.type})
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Hold Ctrl (or Cmd) to select multiple middlewares. All selected middlewares will be assigned with the same priority.
</p>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Priority
</label>
<input
type="number"
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
min="1"
max="1000"
required
/>
<p className="text-xs text-gray-500 mt-1">
Higher priority middlewares are applied first (1-1000)
</p>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
disabled={selectedMiddlewares.length === 0}
>
Add Middlewares
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
default:
return null;
}
};
return (
<div>
<div className="mb-6 flex items-center">
<button
onClick={() => navigateTo('resources')}
className="mr-4 px-3 py-1 bg-gray-200 rounded hover:bg-gray-300"
>
Back
</button>
<h1 className="text-2xl font-bold">Resource: {selectedResource.host}</h1>
{isDisabled && (
<span className="ml-3 px-2 py-1 text-sm rounded-full bg-red-100 text-red-800">
Removed from Pangolin
</span>
)}
</div>
{/* Disabled Resource Warning */}
{isDisabled && (
<div className="bg-red-50 border-l-4 border-red-400 p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="ml-3">
<p className="text-sm text-red-700">
This resource has been removed from Pangolin and is now disabled. Any changes to middleware will not take effect.
</p>
<div className="mt-2 flex space-x-4">
<button
onClick={() => navigateTo('resources')}
className="text-sm text-red-700 underline"
>
Return to resources list
</button>
<button
onClick={handleDeleteResource}
className="text-sm text-red-700 underline"
>
Delete this resource
</button>
</div>
</div>
</div>
</div>
)}
{/* Resource Details */}
<div className="bg-white p-6 rounded-lg shadow mb-6">
<h2 className="text-xl font-semibold mb-4">Resource Details</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">Host</p>
<p className="font-medium flex items-center">
{selectedResource.host}
<a
href={`https://${selectedResource.host}`}
target="_blank"
rel="noopener noreferrer"
className="ml-2 text-sm text-blue-600 hover:underline"
>
Visit
</a>
</p>
</div>
<div>
<p className="text-sm text-gray-500">Service ID</p>
<p className="font-medium">{selectedResource.service_id}</p>
</div>
<div>
<p className="text-sm text-gray-500">Status</p>
<p>
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
isDisabled
? 'bg-red-100 text-red-800'
: assignedMiddlewares.length > 0
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{isDisabled
? 'Disabled'
: assignedMiddlewares.length > 0
? 'Protected'
: 'Not Protected'}
</span>
</p>
</div>
<div>
<p className="text-sm text-gray-500">Resource ID</p>
<p className="font-medium">{selectedResource.id}</p>
</div>
</div>
</div>
{/* Router Configuration Section */}
<div className="bg-white p-6 rounded-lg shadow mb-6">
<h2 className="text-xl font-semibold mb-4">Router Configuration</h2>
<div className="flex flex-wrap gap-4">
<button
onClick={() => openConfigModal('http')}
disabled={isDisabled}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
HTTP Router Configuration
</button>
<button
onClick={() => openConfigModal('tls')}
disabled={isDisabled}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
TLS Certificate Domains
</button>
<button
onClick={() => openConfigModal('tcp')}
disabled={isDisabled}
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
TCP SNI Routing
</button>
<button
onClick={() => openConfigModal('headers')}
disabled={isDisabled}
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Custom Headers
</button>
</div>
{/* Current Configuration Summary */}
<div className="mt-4 p-4 bg-gray-50 rounded border">
<h3 className="font-medium mb-2">Current Configuration</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500">HTTP Entrypoints</p>
<p className="font-medium">{entrypoints || 'websecure'}</p>
</div>
<div>
<p className="text-sm text-gray-500">TLS Certificate Domains</p>
<p className="font-medium">{tlsDomains || 'None'}</p>
</div>
<div>
<p className="text-sm text-gray-500">TCP SNI Routing</p>
<p className="font-medium">{tcpEnabled ? 'Enabled' : 'Disabled'}</p>
</div>
{tcpEnabled && (
<>
<div>
<p className="text-sm text-gray-500">TCP Entrypoints</p>
<p className="font-medium">{tcpEntrypoints || 'tcp'}</p>
</div>
{tcpSNIRule && (
<div className="col-span-2">
<p className="text-sm text-gray-500">TCP SNI Rule</p>
<p className="font-medium font-mono text-sm break-all">{tcpSNIRule}</p>
</div>
)}
</>
)}
{/* Custom Headers summary */}
{Object.keys(customHeaders).length > 0 && (
<div>
<p className="text-sm text-gray-500">Custom Headers</p>
<div className="font-medium">
{Object.entries(customHeaders).map(([key, value]) => (
<div key={key} className="text-sm">
<span className="font-mono">{key}</span>: <span className="font-mono">{value}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Router Priority Configuration */}
<div className="bg-white p-6 rounded-lg shadow mb-6">
<h2 className="text-xl font-semibold mb-4">Router Priority</h2>
<div className="mb-4">
<p className="text-gray-700">
Set the priority of this router. When multiple routers match the same request,
the router with the highest priority (highest number) will be selected first.
</p>
<p className="text-sm text-gray-500 mt-2">
Note: This is different from middleware priority, which controls the order middlewares
are applied within a router.
</p>
</div>
<div className="flex items-center">
<input
type="number"
value={routerPriority}
onChange={(e) => setRouterPriority(parseInt(e.target.value) || 100)}
className="w-24 px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
min="1"
max="1000"
disabled={isDisabled}
/>
<button
onClick={handleUpdateRouterPriority}
className="ml-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isDisabled}
>
Update Priority
</button>
</div>
</div>
{/* Middlewares Section */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Attached Middlewares</h2>
<button
onClick={() => openConfigModal('middlewares')}
disabled={isDisabled || availableMiddlewares.length === 0}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Middleware
</button>
</div>
{assignedMiddlewares.length === 0 ? (
<div className="text-center py-6 text-gray-500">
<p>This resource does not have any middlewares applied to it.</p>
<p>Add a middleware to enhance security or modify behavior.</p>
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Middleware
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Priority
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{assignedMiddlewares.map((middleware) => {
const middlewareDetails = middlewares.find((m) => m.id === middleware.id) || {
id: middleware.id,
name: middleware.name,
type: 'unknown',
};
return (
<tr key={middleware.id}>
<td className="px-6 py-4">
{middlewareDetails && middlewares.length > 0
? formatMiddlewareDisplay(middlewareDetails)
: middleware.name}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{middleware.priority}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => handleRemoveMiddleware(middleware.id)}
className="text-red-600 hover:text-red-900"
disabled={isDisabled}
>
Remove
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Render active modal */}
{renderModal()}
</div>
);
};
export default ResourceDetail;

View File

@@ -0,0 +1,172 @@
import React, { useEffect, useState } from 'react';
import { useResources } from '../../contexts/ResourceContext';
import { LoadingSpinner, ErrorMessage } from '../common';
import { MiddlewareUtils } from '../../services/api';
/**
* ResourcesList component displays all resources with filtering and management options
*/
const ResourcesList = ({ navigateTo }) => {
const {
resources,
loading,
error,
fetchResources,
deleteResource,
setError
} = useResources();
const [searchTerm, setSearchTerm] = useState('');
// Load resources when component mounts
useEffect(() => {
fetchResources();
}, [fetchResources]);
// Handle resource deletion with confirmation
const handleDeleteResource = async (id, host) => {
if (window.confirm(`Are you sure you want to delete the resource "${host}"? This cannot be undone.`)) {
const success = await deleteResource(id);
if (success) {
// Success already handled in the context
}
}
};
// Filter resources based on search term
const filteredResources = resources.filter((resource) =>
resource.host.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading && !resources.length) {
return <LoadingSpinner message="Loading resources..." />;
}
return (
<div>
<h1 className="text-2xl font-bold mb-6">Resources</h1>
{error && (
<ErrorMessage
message="Failed to load resources"
details={error}
onRetry={fetchResources}
onDismiss={() => setError(null)}
/>
)}
<div className="mb-6 flex justify-between">
<div className="relative w-64">
<input
type="text"
placeholder="Search resources..."
className="w-full px-4 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<button
onClick={fetchResources}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
disabled={loading}
>
Refresh
</button>
</div>
<div className="bg-white shadow rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Host
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Middlewares
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredResources.map((resource) => {
const middlewaresList = MiddlewareUtils.parseMiddlewares(resource.middlewares);
const isProtected = middlewaresList.length > 0;
const isDisabled = resource.status === 'disabled';
return (
<tr
key={resource.id}
className={isDisabled ? 'bg-gray-100' : ''}
>
<td className="px-6 py-4 whitespace-nowrap">
{resource.host}
{isDisabled && (
<span className="ml-2 px-2 py-1 text-xs rounded-full bg-red-100 text-red-800">
Removed from Pangolin
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
isDisabled
? 'bg-gray-100 text-gray-800'
: isProtected
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}
>
{isDisabled
? 'Disabled'
: isProtected
? 'Protected'
: 'Not Protected'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{middlewaresList.length > 0
? middlewaresList.length
: 'None'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => navigateTo('resource-detail', resource.id)}
className="text-blue-600 hover:text-blue-900 mr-3"
>
{isDisabled ? 'View' : 'Manage'}
</button>
{isDisabled && (
<button
onClick={() => handleDeleteResource(resource.id, resource.host)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
)}
</td>
</tr>
);
})}
{filteredResources.length === 0 && (
<tr>
<td
colSpan="4"
className="px-6 py-4 text-center text-gray-500"
>
No resources found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default ResourcesList;

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
/**
* HTTP Configuration Modal for managing resource entrypoints
* @param {Object} props
* @param {Object} props.resource - Resource data
* @param {Function} props.onSave - Save handler function
* @param {Function} props.onClose - Close modal handler
*/
const HTTPConfigModal = ({ resource, onSave, onClose }) => {
const [entrypoints, setEntrypoints] = useState(resource.entrypoints || 'websecure');
const [saving, setSaving] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
try {
setSaving(true);
await onSave({ entrypoints });
onClose();
} catch (err) {
alert('Failed to update HTTP configuration');
console.error('HTTP config update error:', err);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h3 className="text-lg font-semibold">HTTP Router Configuration</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
disabled={saving}
aria-label="Close"
>
×
</button>
</div>
<div className="px-6 py-4">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
HTTP Entry Points (comma-separated)
</label>
<input
type="text"
value={entrypoints}
onChange={(e) => setEntrypoints(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="websecure,metrics,api"
required
disabled={saving}
/>
<p className="text-xs text-gray-500 mt-1">
Standard entrypoints: websecure (HTTPS), web (HTTP). Default: websecure
</p>
<p className="text-xs text-gray-500 mt-1">
<strong>Note:</strong> Entrypoints must be defined in your Traefik static configuration file
</p>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Configuration'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default HTTPConfigModal;

View File

@@ -0,0 +1,181 @@
import React, { useState, useEffect } from 'react';
/**
* HeadersConfigModal - A modal for configuring custom headers for a resource
*
* @param {Object} props
* @param {Object} props.resource - The resource being configured
* @param {Function} props.onSave - Function to call with updated headers data
* @param {Function} props.onClose - Function to close the modal
*/
const HeadersConfigModal = ({ resource, onSave, onClose }) => {
const [customHeaders, setCustomHeaders] = useState({});
const [headerKey, setHeaderKey] = useState('');
const [headerValue, setHeaderValue] = useState('');
// Initialize custom headers from resource data
useEffect(() => {
if (resource && resource.custom_headers) {
try {
const parsedHeaders =
typeof resource.custom_headers === 'string'
? JSON.parse(resource.custom_headers)
: resource.custom_headers;
setCustomHeaders(parsedHeaders || {});
} catch (e) {
console.error("Error parsing custom headers:", e);
setCustomHeaders({});
}
} else {
setCustomHeaders({});
}
}, [resource]);
// Function to add new header
const addHeader = () => {
if (!headerKey.trim()) {
alert('Header key cannot be empty');
return;
}
setCustomHeaders({
...customHeaders,
[headerKey]: headerValue
});
setHeaderKey('');
setHeaderValue('');
};
// Function to remove header
const removeHeader = (key) => {
const newHeaders = {...customHeaders};
delete newHeaders[key];
setCustomHeaders(newHeaders);
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
try {
await onSave({ custom_headers: customHeaders });
onClose();
} catch (err) {
alert('Failed to update custom headers');
console.error('Update headers error:', err);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h3 className="text-lg font-semibold">Custom Headers Configuration</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
×
</button>
</div>
<div className="px-6 py-4">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Custom Request Headers
</label>
{/* Current headers list */}
{Object.keys(customHeaders).length > 0 ? (
<div className="mb-4 border rounded p-3">
<h4 className="text-sm font-semibold mb-2">Current Headers</h4>
<ul className="space-y-2">
{Object.entries(customHeaders).map(([key, value]) => (
<li key={key} className="flex justify-between items-center">
<div>
<span className="font-medium">{key}:</span> {value}
</div>
<button
type="button"
onClick={() => removeHeader(key)}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-gray-500 mb-4">No custom headers configured.</p>
)}
{/* Add new header */}
<div className="border rounded p-3">
<h4 className="text-sm font-semibold mb-2">Add New Header</h4>
<div className="grid grid-cols-5 gap-2 mb-2">
<div className="col-span-2">
<input
type="text"
value={headerKey}
onChange={(e) => setHeaderKey(e.target.value)}
placeholder="Header name"
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-2">
<input
type="text"
value={headerValue}
onChange={(e) => setHeaderValue(e.target.value)}
placeholder="Header value"
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="col-span-1">
<button
type="button"
onClick={addHeader}
className="w-full px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Add
</button>
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
Common examples: Host, X-Forwarded-Host
</p>
<p className="text-xs text-gray-500 mt-1">
<strong>Host</strong>: To modify the hostname sent to the backend service
</p>
<p className="text-xs text-gray-500 mt-1">
<strong>X-Forwarded-Host</strong>: To pass the original hostname to the backend
</p>
</div>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700"
>
Save Headers
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default HeadersConfigModal;

View File

@@ -0,0 +1,122 @@
import React, { useState } from 'react';
/**
* TCP Configuration Modal for TCP SNI routing
*/
const TCPConfigModal = ({ resource, onSave, onClose }) => {
const [tcpEnabled, setTCPEnabled] = useState(resource.tcp_enabled || false);
const [tcpEntrypoints, setTCPEntrypoints] = useState(resource.tcp_entrypoints || 'tcp');
const [tcpSNIRule, setTCPSNIRule] = useState(resource.tcp_sni_rule || '');
const handleSubmit = async (e) => {
e.preventDefault();
try {
await onSave({
tcp_enabled: tcpEnabled,
tcp_entrypoints: tcpEntrypoints,
tcp_sni_rule: tcpSNIRule
});
onClose();
} catch (err) {
alert('Failed to update TCP configuration');
console.error('TCP config update error:', err);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h3 className="text-lg font-semibold">TCP SNI Routing Configuration</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
×
</button>
</div>
<div className="px-6 py-4">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2 flex items-center">
<input
type="checkbox"
checked={tcpEnabled}
onChange={(e) => setTCPEnabled(e.target.checked)}
className="mr-2"
/>
Enable TCP SNI Routing
</label>
<p className="text-xs text-gray-500 mt-1">
Creates a separate TCP router with SNI matching rules
</p>
</div>
{tcpEnabled && (
<>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
TCP Entry Points (comma-separated)
</label>
<input
type="text"
value={tcpEntrypoints}
onChange={(e) => setTCPEntrypoints(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="tcp"
required
/>
<p className="text-xs text-gray-500 mt-1">
Standard TCP entrypoint: tcp. Default: tcp
</p>
</div>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
TCP SNI Matching Rule
</label>
<input
type="text"
value={tcpSNIRule}
onChange={(e) => setTCPSNIRule(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`HostSNI(\`${resource.host}\`)`}
/>
<p className="text-xs text-gray-500 mt-1">
SNI rule using HostSNI or HostSNIRegexp matchers
</p>
<p className="text-xs text-gray-500 mt-1">Examples:</p>
<ul className="text-xs text-gray-500 mt-1 list-disc pl-5">
<li>Match specific domain: <code>{`HostSNI(\`${resource.host}\`)`}</code></li>
<li>Match with wildcard: <code>{`HostSNIRegexp(\`^.+\\.example\\.com$\`)`}</code></li>
</ul>
<p className="text-xs text-gray-500 mt-1">
If empty, defaults to <code>{`HostSNI(\`${resource.host}\`)`}</code>
</p>
</div>
</>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-purple-600 text-white rounded hover:bg-purple-700"
>
Save TCP Configuration
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default TCPConfigModal;

View File

@@ -0,0 +1,88 @@
import React, { useState } from 'react';
/**
* TLS Configuration Modal for managing certificate domains
* @param {Object} props
* @param {Object} props.resource - Resource data
* @param {Function} props.onSave - Save handler function
* @param {Function} props.onClose - Close modal handler
*/
const TLSConfigModal = ({ resource, onSave, onClose }) => {
const [tlsDomains, setTlsDomains] = useState(resource.tls_domains || '');
const [saving, setSaving] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
try {
setSaving(true);
await onSave({ tls_domains: tlsDomains });
onClose();
} catch (err) {
alert('Failed to update TLS certificate domains');
console.error('TLS config update error:', err);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-lg w-full max-w-md">
<div className="flex justify-between items-center px-6 py-4 border-b">
<h3 className="text-lg font-semibold">TLS Certificate Domains</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
disabled={saving}
aria-label="Close"
>
×
</button>
</div>
<div className="px-6 py-4">
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Additional Certificate Domains (comma-separated)
</label>
<input
type="text"
value={tlsDomains}
onChange={(e) => setTlsDomains(e.target.value)}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="example.com,*.example.com"
disabled={saving}
/>
<p className="text-xs text-gray-500 mt-1">
Extra domains to include in the TLS certificate (Subject Alternative Names)
</p>
<p className="text-xs text-gray-500 mt-1">
Main domain ({resource.host}) will be automatically included
</p>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-gray-200 rounded hover:bg-gray-300"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Certificate Domains'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default TLSConfigModal;

View File

@@ -0,0 +1,92 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
// Create the context
const AppContext = createContext();
/**
* App provider component
* Manages global application state
*
* @param {Object} props
* @param {ReactNode} props.children - Child components
* @returns {JSX.Element}
*/
export const AppProvider = ({ children }) => {
const [page, setPage] = useState('dashboard');
const [resourceId, setResourceId] = useState(null);
const [middlewareId, setMiddlewareId] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
// Initialize dark mode on mount
useEffect(() => {
// Check for saved preference or use system preference
const savedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldUseDarkMode = savedTheme === 'dark' || (!savedTheme && prefersDark);
if (shouldUseDarkMode) {
document.documentElement.classList.add('dark-mode');
setIsDarkMode(true);
} else {
document.documentElement.classList.remove('dark-mode');
setIsDarkMode(false);
}
}, []);
/**
* Navigate to a different page
*
* @param {string} pageId - Page identifier
* @param {string|null} id - Optional resource or middleware ID
*/
const navigateTo = (pageId, id = null) => {
setPage(pageId);
if (pageId === 'resource-detail') {
setResourceId(id);
setMiddlewareId(null);
setIsEditing(false);
} else if (pageId === 'middleware-form') {
setMiddlewareId(id);
setResourceId(null);
setIsEditing(!!id);
} else {
// Reset IDs for other pages
setResourceId(null);
setMiddlewareId(null);
setIsEditing(false);
}
};
// Create context value object
const value = {
page,
resourceId,
middlewareId,
isEditing,
isDarkMode,
setIsDarkMode,
navigateTo
};
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
};
/**
* Custom hook to use the app context
*
* @returns {Object} App context value
*/
export const useApp = () => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useApp must be used within an AppProvider');
}
return context;
};

View File

@@ -0,0 +1,275 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import { MiddlewareService, MiddlewareUtils } from '../services/api';
// Create the context
const MiddlewareContext = createContext();
/**
* Middleware provider component
* Manages middleware state and provides data to child components
*
* @param {Object} props
* @param {ReactNode} props.children - Child components
* @returns {JSX.Element}
*/
export const MiddlewareProvider = ({ children }) => {
const [middlewares, setMiddlewares] = useState([]);
const [selectedMiddleware, setSelectedMiddleware] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/**
* Fetch all middlewares
*/
const fetchMiddlewares = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await MiddlewareService.getMiddlewares();
setMiddlewares(data);
} catch (err) {
setError('Failed to load middlewares');
console.error('Error fetching middlewares:', err);
} finally {
setLoading(false);
}
}, []);
/**
* Fetch a specific middleware by ID
*
* @param {string} id - Middleware ID
*/
const fetchMiddleware = useCallback(async (id) => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await MiddlewareService.getMiddleware(id);
setSelectedMiddleware(data);
} catch (err) {
setError(`Failed to load middleware: ${err.message}`);
console.error(`Error fetching middleware ${id}:`, err);
} finally {
setLoading(false);
}
}, []);
/**
* Create a new middleware
*
* @param {Object} middlewareData - Middleware data
* @returns {Promise<Object|null>} - Created middleware or null on error
*/
const createMiddleware = useCallback(async (middlewareData) => {
try {
setLoading(true);
setError(null);
const newMiddleware = await MiddlewareService.createMiddleware(middlewareData);
// Update middlewares list
setMiddlewares(prevMiddlewares => [
...prevMiddlewares,
newMiddleware
]);
return newMiddleware;
} catch (err) {
setError(`Failed to create middleware: ${err.message}`);
console.error('Error creating middleware:', err);
return null;
} finally {
setLoading(false);
}
}, []);
/**
* Update an existing middleware
*
* @param {string} id - Middleware ID
* @param {Object} middlewareData - Updated middleware data
* @returns {Promise<Object|null>} - Updated middleware or null on error
*/
const updateMiddleware = useCallback(async (id, middlewareData) => {
try {
setLoading(true);
setError(null);
const updatedMiddleware = await MiddlewareService.updateMiddleware(id, middlewareData);
// Update middlewares list
setMiddlewares(prevMiddlewares =>
prevMiddlewares.map(middleware =>
middleware.id === id ? updatedMiddleware : middleware
)
);
// Update selected middleware if relevant
if (selectedMiddleware && selectedMiddleware.id === id) {
setSelectedMiddleware(updatedMiddleware);
}
return updatedMiddleware;
} catch (err) {
setError(`Failed to update middleware: ${err.message}`);
console.error(`Error updating middleware ${id}:`, err);
return null;
} finally {
setLoading(false);
}
}, [selectedMiddleware]);
/**
* Delete a middleware
*
* @param {string} id - Middleware ID
* @returns {Promise<boolean>} - Success status
*/
const deleteMiddleware = useCallback(async (id) => {
try {
setLoading(true);
setError(null);
await MiddlewareService.deleteMiddleware(id);
// Update middlewares list
setMiddlewares(prevMiddlewares =>
prevMiddlewares.filter(middleware => middleware.id !== id)
);
// Clear selected middleware if relevant
if (selectedMiddleware && selectedMiddleware.id === id) {
setSelectedMiddleware(null);
}
return true;
} catch (err) {
setError(`Failed to delete middleware: ${err.message}`);
console.error(`Error deleting middleware ${id}:`, err);
return false;
} finally {
setLoading(false);
}
}, [selectedMiddleware]);
/**
* Get a configuration template for middleware type
*
* @param {string} type - Middleware type
* @returns {string} - JSON template string
*/
const getConfigTemplate = useCallback((type) => {
return MiddlewareUtils.getConfigTemplate(type);
}, []);
/**
* Format middleware display for UI
*
* @param {Object} middleware - Middleware object
* @returns {JSX.Element} - Formatted middleware display
*/
const formatMiddlewareDisplay = useCallback((middleware) => {
// Determine if middleware is a chain type
const isChain = middleware.type === 'chain';
// Parse config safely
let configObj = middleware.config;
if (typeof configObj === 'string') {
try {
configObj = JSON.parse(configObj);
} catch (error) {
console.error('Error parsing middleware config:', error);
configObj = {};
}
}
return (
<div className="py-2">
<div className="flex items-center">
<span className="font-medium">{middleware.name}</span>
<span className="px-2 py-1 ml-2 text-xs rounded-full bg-blue-100 text-blue-800">
{middleware.type}
</span>
{isChain && (
<span className="ml-2 text-gray-500 text-sm">
(Middleware Chain)
</span>
)}
</div>
{/* Display chained middlewares for chain type */}
{isChain && configObj.middlewares && configObj.middlewares.length > 0 && (
<div className="ml-4 mt-1 border-l-2 border-gray-200 pl-3">
<div className="text-sm text-gray-600 mb-1">Chain contains:</div>
<ul className="space-y-1">
{configObj.middlewares.map((id, index) => {
const chainedMiddleware = middlewares.find((m) => m.id === id);
return (
<li key={index} className="text-sm">
{index + 1}.{' '}
{chainedMiddleware ? (
<span className="font-medium">
{chainedMiddleware.name}{' '}
<span className="text-xs text-gray-500">
({chainedMiddleware.type})
</span>
</span>
) : (
<span>
{id}{' '}
<span className="text-xs text-gray-500">
(unknown middleware)
</span>
</span>
)}
</li>
);
})}
</ul>
</div>
)}
</div>
);
}, [middlewares]);
// Fetch middlewares on initial mount
useEffect(() => {
fetchMiddlewares();
}, [fetchMiddlewares]);
// Create context value object
const value = {
middlewares,
selectedMiddleware,
loading,
error,
fetchMiddlewares,
fetchMiddleware,
createMiddleware,
updateMiddleware,
deleteMiddleware,
getConfigTemplate,
formatMiddlewareDisplay,
setError, // Expose setError for components to clear errors
};
return (
<MiddlewareContext.Provider value={value}>
{children}
</MiddlewareContext.Provider>
);
};
/**
* Custom hook to use the middleware context
*
* @returns {Object} Middleware context value
*/
export const useMiddlewares = () => {
const context = useContext(MiddlewareContext);
if (context === undefined) {
throw new Error('useMiddlewares must be used within a MiddlewareProvider');
}
return context;
};

View File

@@ -0,0 +1,256 @@
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
import { ResourceService } from '../services/api';
// Create the context
const ResourceContext = createContext();
/**
* Resource provider component
* Manages resource state and provides data to child components
*
* @param {Object} props
* @param {ReactNode} props.children - Child components
* @returns {JSX.Element}
*/
export const ResourceProvider = ({ children }) => {
const { resources, fetchResources } = useResources();
const [selectedResource, setSelectedResource] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
/**
* Fetch all resources
*/
const fetchResources = useCallback(async () => {
try {
setLoading(true);
setError(null);
const data = await ResourceService.getResources();
setResources(data);
} catch (err) {
setError('Failed to load resources');
console.error('Error fetching resources:', err);
} finally {
setLoading(false);
}
}, []);
/**
* Fetch a specific resource by ID
*
* @param {string} id - Resource ID
*/
const fetchResource = useCallback(async (id) => {
if (!id) return;
try {
setLoading(true);
setError(null);
const data = await ResourceService.getResource(id);
setSelectedResource(data);
} catch (err) {
setError(`Failed to load resource: ${err.message}`);
console.error(`Error fetching resource ${id}:`, err);
} finally {
setLoading(false);
}
}, []);
/**
* Delete a resource
*
* @param {string} id - Resource ID
* @returns {Promise<boolean>} - Success status
*/
const deleteResource = useCallback(async (id) => {
try {
setLoading(true);
setError(null);
await ResourceService.deleteResource(id);
// Update resources list
setResources(prevResources =>
prevResources.filter(resource => resource.id !== id)
);
return true;
} catch (err) {
setError(`Failed to delete resource: ${err.message}`);
console.error(`Error deleting resource ${id}:`, err);
return false;
} finally {
setLoading(false);
}
}, []);
/**
* Assign a middleware to a resource
*
* @param {string} resourceId - Resource ID
* @param {string} middlewareId - Middleware ID
* @param {number} priority - Priority level
* @returns {Promise<boolean>} - Success status
*/
const assignMiddleware = useCallback(async (resourceId, middlewareId, priority = 100) => {
try {
setLoading(true);
setError(null);
await ResourceService.assignMiddleware(resourceId, {
middleware_id: middlewareId,
priority
});
// Refresh the resource
await fetchResource(resourceId);
return true;
} catch (err) {
setError(`Failed to assign middleware: ${err.message}`);
console.error('Error assigning middleware:', err);
return false;
} finally {
setLoading(false);
}
}, [fetchResource]);
/**
* Assign multiple middlewares to a resource
*
* @param {string} resourceId - Resource ID
* @param {Array} middlewares - Array of middleware objects
* @returns {Promise<boolean>} - Success status
*/
const assignMultipleMiddlewares = useCallback(async (resourceId, middlewares) => {
try {
setLoading(true);
setError(null);
await ResourceService.assignMultipleMiddlewares(resourceId, {
middlewares: middlewares
});
// Refresh the resource
await fetchResource(resourceId);
return true;
} catch (err) {
setError(`Failed to assign middlewares: ${err.message}`);
console.error('Error assigning middlewares:', err);
return false;
} finally {
setLoading(false);
}
}, [fetchResource]);
/**
* Remove a middleware from a resource
*
* @param {string} resourceId - Resource ID
* @param {string} middlewareId - Middleware ID to remove
* @returns {Promise<boolean>} - Success status
*/
const removeMiddleware = useCallback(async (resourceId, middlewareId) => {
try {
setLoading(true);
setError(null);
await ResourceService.removeMiddleware(resourceId, middlewareId);
// Refresh the resource
await fetchResource(resourceId);
return true;
} catch (err) {
setError(`Failed to remove middleware: ${err.message}`);
console.error('Error removing middleware:', err);
return false;
} finally {
setLoading(false);
}
}, [fetchResource]);
/**
* Update resource configuration
*
* @param {string} resourceId - Resource ID
* @param {string} configType - Configuration type (http, tls, tcp, headers, priority)
* @param {Object} data - Configuration data
* @returns {Promise<boolean>} - Success status
*/
const updateResourceConfig = useCallback(async (resourceId, configType, data) => {
try {
setLoading(true);
setError(null);
// Call the appropriate API method based on config type
switch (configType) {
case 'http':
await ResourceService.updateHTTPConfig(resourceId, data);
break;
case 'tls':
await ResourceService.updateTLSConfig(resourceId, data);
break;
case 'tcp':
await ResourceService.updateTCPConfig(resourceId, data);
break;
case 'headers':
await ResourceService.updateHeadersConfig(resourceId, data);
break;
case 'priority':
await ResourceService.updateRouterPriority(resourceId, data);
break;
default:
throw new Error(`Unknown config type: ${configType}`);
}
// Refresh the resource
await fetchResource(resourceId);
return true;
} catch (err) {
setError(`Failed to update ${configType} configuration: ${err.message}`);
console.error(`Error updating ${configType} config:`, err);
return false;
} finally {
setLoading(false);
}
}, [fetchResource]);
// Fetch resources on initial mount
useEffect(() => {
fetchResources();
}, [fetchResources]);
// Create context value object
const value = {
resources,
selectedResource,
loading,
error,
fetchResources,
fetchResource,
deleteResource,
assignMiddleware,
assignMultipleMiddlewares,
removeMiddleware,
updateResourceConfig,
setError, // Expose setError for components to clear errors
};
return (
<ResourceContext.Provider value={value}>
{children}
</ResourceContext.Provider>
);
};
/**
* Custom hook to use the resource context
*
* @returns {Object} Resource context value
*/
export const useResources = () => {
const context = useContext(ResourceContext);
if (context === undefined) {
throw new Error('useResources must be used within a ResourceProvider');
}
return context;
};

8
ui/src/contexts/index.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* Context providers export file
* This file exports all context providers and hooks to simplify imports
*/
export { AppProvider, useApp } from './AppContext';
export { ResourceProvider, useResources } from './ResourceContext';
export { MiddlewareProvider, useMiddlewares } from './MiddlewareContext';

View File

@@ -1,9 +1,11 @@
// ui/src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles/main.css';
/**
* Initialize and render the React application
*/
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>

286
ui/src/services/api.js Normal file
View File

@@ -0,0 +1,286 @@
/**
* API service layer for the Middleware Manager
* Centralizes all API calls and error handling
*/
const API_URL = '/api';
/**
* Generic request handler with error handling
* @param {string} url - API endpoint
* @param {Object} options - Fetch options
* @returns {Promise<any>} - Response data
*/
const request = async (url, options = {}) => {
try {
const response = await fetch(url, options);
// If the response is not ok, throw an error
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const error = new Error(errorData.message || `HTTP error ${response.status}`);
error.status = response.status;
error.data = errorData;
throw error;
}
// Parse JSON response
return await response.json();
} catch (error) {
console.error(`API error: ${error.message}`, error);
throw error;
}
};
/**
* Resource-related API calls
*/
export const ResourceService = {
/**
* Get all resources
* @returns {Promise<Array>} - List of resources
*/
getResources: () => request(`${API_URL}/resources`),
/**
* Get a specific resource by ID
* @param {string} id - Resource ID
* @returns {Promise<Object>} - Resource data
*/
getResource: (id) => request(`${API_URL}/resources/${id}`),
/**
* Delete a resource
* @param {string} id - Resource ID
* @returns {Promise<Object>} - Response message
*/
deleteResource: (id) => request(`${API_URL}/resources/${id}`, { method: 'DELETE' }),
/**
* Assign a middleware to a resource
* @param {string} resourceId - Resource ID
* @param {Object} data - Middleware assignment data
* @returns {Promise<Object>} - Response data
*/
assignMiddleware: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/middlewares`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Assign multiple middlewares to a resource
* @param {string} resourceId - Resource ID
* @param {Object} data - Multiple middleware assignment data
* @returns {Promise<Object>} - Response data
*/
assignMultipleMiddlewares: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/middlewares/bulk`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Remove a middleware from a resource
* @param {string} resourceId - Resource ID
* @param {string} middlewareId - Middleware ID
* @returns {Promise<Object>} - Response message
*/
removeMiddleware: (resourceId, middlewareId) => request(
`${API_URL}/resources/${resourceId}/middlewares/${middlewareId}`,
{ method: 'DELETE' }
),
/**
* Update HTTP configuration
* @param {string} resourceId - Resource ID
* @param {Object} data - HTTP configuration data
* @returns {Promise<Object>} - Response data
*/
updateHTTPConfig: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/config/http`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Update TLS configuration
* @param {string} resourceId - Resource ID
* @param {Object} data - TLS configuration data
* @returns {Promise<Object>} - Response data
*/
updateTLSConfig: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/config/tls`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Update TCP configuration
* @param {string} resourceId - Resource ID
* @param {Object} data - TCP configuration data
* @returns {Promise<Object>} - Response data
*/
updateTCPConfig: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/config/tcp`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Update headers configuration
* @param {string} resourceId - Resource ID
* @param {Object} data - Headers configuration data
* @returns {Promise<Object>} - Response data
*/
updateHeadersConfig: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/config/headers`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Update router priority
* @param {string} resourceId - Resource ID
* @param {Object} data - Router priority data
* @returns {Promise<Object>} - Response data
*/
updateRouterPriority: (resourceId, data) => request(
`${API_URL}/resources/${resourceId}/config/priority`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
)
};
/**
* Middleware-related API calls
*/
export const MiddlewareService = {
/**
* Get all middlewares
* @returns {Promise<Array>} - List of middlewares
*/
getMiddlewares: () => request(`${API_URL}/middlewares`),
/**
* Get a specific middleware by ID
* @param {string} id - Middleware ID
* @returns {Promise<Object>} - Middleware data
*/
getMiddleware: (id) => request(`${API_URL}/middlewares/${id}`),
/**
* Create a new middleware
* @param {Object} data - Middleware data
* @returns {Promise<Object>} - Created middleware
*/
createMiddleware: (data) => request(
`${API_URL}/middlewares`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Update a middleware
* @param {string} id - Middleware ID
* @param {Object} data - Updated middleware data
* @returns {Promise<Object>} - Updated middleware
*/
updateMiddleware: (id, data) => request(
`${API_URL}/middlewares/${id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}
),
/**
* Delete a middleware
* @param {string} id - Middleware ID
* @returns {Promise<Object>} - Response message
*/
deleteMiddleware: (id) => request(
`${API_URL}/middlewares/${id}`,
{ method: 'DELETE' }
)
};
/**
* Utility functions for middleware management
*/
export const MiddlewareUtils = {
/**
* Parses middleware string into an array of middleware objects
* @param {string} middlewaresStr - Comma-separated middleware string
* @returns {Array} - Array of middleware objects
*/
parseMiddlewares: (middlewaresStr) => {
// Handle empty or invalid input
if (!middlewaresStr || typeof middlewaresStr !== 'string') return [];
return middlewaresStr
.split(',')
.filter(Boolean)
.map((item) => {
const [id, name, priority] = item.split(':');
return {
id,
name,
priority: parseInt(priority, 10) || 100, // Default priority if invalid
};
});
},
/**
* Gets template config for middleware type
* @param {string} type - Middleware type
* @returns {string} - JSON template
*/
getConfigTemplate: (type) => {
const templates = {
basicAuth: '{\n "users": [\n "admin:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/"\n ]\n}',
digestAuth: '{\n "users": [\n "test:traefik:a2688e031edb4be6a3797f3882655c05"\n ]\n}',
forwardAuth: '{\n "address": "http://auth-service:9090/auth",\n "trustForwardHeader": true,\n "authResponseHeaders": [\n "X-Auth-User",\n "X-Auth-Roles"\n ]\n}',
ipWhiteList: '{\n "sourceRange": [\n "127.0.0.1/32",\n "192.168.1.0/24"\n ]\n}',
ipAllowList: '{\n "sourceRange": [\n "127.0.0.1/32",\n "192.168.1.0/24"\n ]\n}',
rateLimit: '{\n "average": 100,\n "burst": 50\n}',
headers: '{\n "browserXssFilter": true,\n "contentTypeNosniff": true,\n "customFrameOptionsValue": "SAMEORIGIN",\n "forceSTSHeader": true,\n "stsIncludeSubdomains": true,\n "stsSeconds": 63072000,\n "customResponseHeaders": {\n "X-Custom-Header": "value",\n "Server": ""\n }\n}',
stripPrefix: '{\n "prefixes": [\n "/api"\n ],\n "forceSlash": true\n}',
addPrefix: '{\n "prefix": "/api"\n}',
redirectRegex: '{\n "regex": "^http://(.*)$",\n "replacement": "https://${1}",\n "permanent": true\n}',
redirectScheme: '{\n "scheme": "https",\n "permanent": true,\n "port": "443"\n}',
chain: '{\n "middlewares": []\n}',
replacePath: '{\n "path": "/newpath"\n}',
replacePathRegex: '{\n "regex": "^/api/(.*)",\n "replacement": "/bar/$1"\n}',
stripPrefixRegex: '{\n "regex": [\n "^/api/v\\\\d+/"\n ]\n}',
plugin: '{\n "plugin-name": {\n "option1": "value1",\n "option2": "value2"\n }\n}'
};
return templates[type] || '{}';
}
};