mirror of
https://github.com/hhftechnology/middleware-manager.git
synced 2025-12-19 23:39:36 -06:00
refactoring
This commit is contained in:
47
.github/workflows/main.yml
vendored
47
.github/workflows/main.yml
vendored
@@ -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
90
api/errors/errors.go
Normal 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)
|
||||
}
|
||||
1396
api/handlers.go
1396
api/handlers.go
File diff suppressed because it is too large
Load Diff
117
api/handlers/common.go
Normal file
117
api/handlers/common.go
Normal 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
495
api/handlers/config.go
Normal 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
367
api/handlers/middlewares.go
Normal 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
561
api/handlers/resources.go
Normal 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"})
|
||||
}
|
||||
@@ -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
98
database/transaction.go
Normal 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
484
models/middleware_types.go
Normal 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
|
||||
}
|
||||
}
|
||||
2544
ui/src/App.js
2544
ui/src/App.js
File diff suppressed because it is too large
Load Diff
67
ui/src/components/common/DarkModeToggle.js
Normal file
67
ui/src/components/common/DarkModeToggle.js
Normal 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;
|
||||
74
ui/src/components/common/ErrorMessage.js
Normal file
74
ui/src/components/common/ErrorMessage.js
Normal 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;
|
||||
61
ui/src/components/common/Header.js
Normal file
61
ui/src/components/common/Header.js
Normal 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;
|
||||
32
ui/src/components/common/LoadingSpinner.js
Normal file
32
ui/src/components/common/LoadingSpinner.js
Normal 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;
|
||||
9
ui/src/components/common/index.js
Normal file
9
ui/src/components/common/index.js
Normal 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';
|
||||
222
ui/src/components/dashboard/Dashboard.js
Normal file
222
ui/src/components/dashboard/Dashboard.js
Normal 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;
|
||||
91
ui/src/components/dashboard/ResourceSummary.js
Normal file
91
ui/src/components/dashboard/ResourceSummary.js
Normal 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;
|
||||
46
ui/src/components/dashboard/StatCard.js
Normal file
46
ui/src/components/dashboard/StatCard.js
Normal 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;
|
||||
302
ui/src/components/middlewares/MiddlewareForm.js
Normal file
302
ui/src/components/middlewares/MiddlewareForm.js
Normal 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;
|
||||
190
ui/src/components/middlewares/MiddlewaresList.js
Normal file
190
ui/src/components/middlewares/MiddlewaresList.js
Normal 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;
|
||||
644
ui/src/components/resources/ResourceDetail.js
Normal file
644
ui/src/components/resources/ResourceDetail.js
Normal 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;
|
||||
172
ui/src/components/resources/ResourcesList.js
Normal file
172
ui/src/components/resources/ResourcesList.js
Normal 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;
|
||||
89
ui/src/components/resources/config/HTTPConfigModal.js
Normal file
89
ui/src/components/resources/config/HTTPConfigModal.js
Normal 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;
|
||||
181
ui/src/components/resources/config/HeadersConfigModal.js
Normal file
181
ui/src/components/resources/config/HeadersConfigModal.js
Normal 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;
|
||||
122
ui/src/components/resources/config/TCPConfigModal.js
Normal file
122
ui/src/components/resources/config/TCPConfigModal.js
Normal 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;
|
||||
88
ui/src/components/resources/config/TLSConfigModal.js
Normal file
88
ui/src/components/resources/config/TLSConfigModal.js
Normal 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;
|
||||
92
ui/src/contexts/AppContext.js
Normal file
92
ui/src/contexts/AppContext.js
Normal 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;
|
||||
};
|
||||
275
ui/src/contexts/MiddlewareContext.js
Normal file
275
ui/src/contexts/MiddlewareContext.js
Normal 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;
|
||||
};
|
||||
256
ui/src/contexts/ResourceContext.js
Normal file
256
ui/src/contexts/ResourceContext.js
Normal 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
8
ui/src/contexts/index.js
Normal 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';
|
||||
@@ -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
286
ui/src/services/api.js
Normal 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] || '{}';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user