diff --git a/.gitignore b/.gitignore index 2a8ac54..9fec30a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ bin/ .vscode/ +__debug_* \ No newline at end of file diff --git a/data/config.db b/data/config.db index 403b913..8d9b397 100644 --- a/data/config.db +++ b/data/config.db Binary files differ diff --git a/data/lds.yaml b/data/lds.yaml index 81b5897..afa64e3 100644 --- a/data/lds.yaml +++ b/data/lds.yaml @@ -1,37 +1,5 @@ resources: - "@type": type.googleapis.com/envoy.config.listener.v3.Listener - name: http_listener - address: - socket_address: { address: 0.0.0.0, port_value: 10000 } - filter_chains: - - filters: - - name: envoy.filters.network.http_connection_manager - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager - stat_prefix: ingress_http - codec_type: AUTO - route_config: - name: ingress_generic_insecure - virtual_hosts: - - name: http_to_https - domains: ["*"] - routes: - - match: { prefix : "/.well-known/acme-challenge"} - route: { cluster: _acme_renewer } - - match: { prefix: "/" } - redirect: { https_redirect: true } - - name: video_insecure - domains: ["video.jerxie.com" , "video.local:10000"] - routes: - - match: { prefix : "/.well-known/acme-challenge"} - route: { cluster: _acme_renewer } - - match: { prefix : "/"} - route: { cluster: _nas_video } - http_filters: - - name: envoy.filters.http.router - typed_config: - "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router -- "@type": type.googleapis.com/envoy.config.listener.v3.Listener name: https_listener address: socket_address: { address: 0.0.0.0, port_value: 10001 } diff --git a/internal/USECASE.md b/internal/USECASE.md new file mode 100644 index 0000000..369fde0 --- /dev/null +++ b/internal/USECASE.md @@ -0,0 +1,28 @@ + +# Append Filter Chain to Listener + +curl -X POST http://localhost:8080/append-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "yaml": "filters:\n - name: envoy.filters.network.http_connection_manager\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager\n stat_prefix: ingress_http\n codec_type: AUTO\n route_config:\n virtual_hosts:\n - name: baby_service\n domains: [\"baby.jerxie.com\"]\n routes:\n - match: { prefix: \"/\" }\n route: { cluster: \"_baby_buddy\"}\n http_filters:\n - name: envoy.filters.http.router\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router\nfilter_chain_match:\n server_names: [\"baby.jerxie.com\"]\ntransport_socket:\n name: envoy.transport_sockets.tls\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext\n common_tls_context:\n tls_certificates:\n - certificate_chain: { filename: \"/etc/certs/downstream/baby.jerxie.com/fullchain.pem\" }\n private_key: { filename: \"/etc/certs/downstream/baby.jerxie.com/privkey.pem\" }" +}' + +# Add a new listener + +# Update a pre-existing filter chain +curl -X POST http://localhost:8080/add-listener -H "Content-Type: application/json" -d '{ + "yaml": "listener yaml..." +}' + + +curl -X POST http://localhost:8080/update-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "yaml": "filters:\n - name: envoy.filters.network.http_connection_manager\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager\n stat_prefix: ingress_http_updated\n codec_type: AUTO\n route_config:\n virtual_hosts:\n - name: baby_service_updated\n domains: [\"baby.jerxie.com\"]\n routes:\n - match: { prefix: \"/\" }\n route: { cluster: \"_baby_buddy_updated\"}\n http_filters:\n - name: envoy.filters.http.router\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router\nfilter_chain_match:\n server_names: [\"baby.jerxie.com\"]\ntransport_socket:\n name: envoy.transport_sockets.tls\n typed_config:\n \"@type\": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext\n common_tls_context:\n tls_certificates:\n - certificate_chain: { filename: \"/etc/certs/downstream/baby.jerxie.com/fullchain34.pem\" }\n private_key: { filename: \"/etc/certs/downstream/baby.jerxie.com/privkey43.pem\" }" +}' + + +# Delete a filter chain + +curl -X POST http://localhost:8080/remove-filter-chain -H "Content-Type: application/json" -d '{ + "listener_name": "https_listener", + "domains" : ["baby1.jerxie.com"] +}' \ No newline at end of file diff --git a/internal/api.go b/internal/api.go index 44bad17..87c9807 100644 --- a/internal/api.go +++ b/internal/api.go @@ -29,14 +29,14 @@ // Cluster Handlers mux.HandleFunc("/add-cluster", func(w http.ResponseWriter, r *http.Request) { - api.addResourceHandler(w, r, resourcev3.ClusterType, func(req interface{}) types.Resource { + api.addResourcesHandler(w, r, resourcev3.ClusterType, func(req interface{}) []types.Resource { cr := req.(*internalapi.AddClusterRequest) - cl, er := snapshot.LoadClusterFromYAML(context.TODO(), cr.YAML) + cls, er := snapshot.LoadResourceFromYAML(context.TODO(), cr.YAML, resourcev3.ClusterType) if er != nil { http.Error(w, "failed to load cluster", http.StatusBadRequest) return nil } - return cl + return cls }) }) mux.HandleFunc("/disable-cluster", func(w http.ResponseWriter, r *http.Request) { @@ -51,14 +51,14 @@ // Listener Handlers mux.HandleFunc("/add-listener", func(w http.ResponseWriter, r *http.Request) { - api.addResourceHandler(w, r, resourcev3.ListenerType, func(req interface{}) types.Resource { + api.addResourcesHandler(w, r, resourcev3.ListenerType, func(req interface{}) []types.Resource { lr := req.(*internalapi.AddListenerRequest) - ls, err := snapshot.LoadListenerFromYAML(context.TODO(), lr.YAML) + lss, err := snapshot.LoadResourceFromYAML(context.TODO(), lr.YAML, resourcev3.ListenerType) if err != nil { http.Error(w, "failed to load listener", http.StatusBadRequest) return nil } - return ls + return lss }) }) mux.HandleFunc("/disable-listener", func(w http.ResponseWriter, r *http.Request) { @@ -79,6 +79,10 @@ api.updateFilterChainHandler(w, r) }) + mux.HandleFunc("/remove-filter-chain", func(w http.ResponseWriter, r *http.Request) { + api.removeFilterChainHandler(w, r) + }) + // Query / List Handlers mux.HandleFunc("/list-clusters", func(w http.ResponseWriter, r *http.Request) { api.listResourceHandler(w, r, resourcev3.ClusterType) diff --git a/internal/api/types.go b/internal/api/types.go index 5425675..fb34ae6 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -50,6 +50,12 @@ YAML string `json:"yaml"` } +// RemoveFilterChainRequest defines payload to remove a filter chain to a given listener +type RemoveFilterChainRequest struct { + ListenerName string `json:"listener_name"` + Domains []string `json:"domains"` +} + // RemoveListenerRequest defines payload to remove a listener (Not explicitly used in handlers, but included for completeness) type RemoveListenerRequest struct { Name string `json:"name"` diff --git a/internal/api_handlers.go b/internal/api_handlers.go index f711bb5..7332f08 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -125,8 +125,10 @@ // ---------------- Generic REST Handlers ---------------- +// ---------------- Generic REST Handlers ---------------- + // addResourceHandler handles adding a resource (Cluster, Listener) to the cache and persisting it. -func (api *API) addResourceHandler(w http.ResponseWriter, r *http.Request, typ resourcev3.Type, createFn func(interface{}) types.Resource) { +func (api *API) addResourcesHandler(w http.ResponseWriter, r *http.Request, typ resourcev3.Type, createFn func(interface{}) []types.Resource) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -151,19 +153,47 @@ return } - res := createFn(req) - if err := api.Manager.AddResourceToSnapshot(res, typ); err != nil { - http.Error(w, fmt.Sprintf("failed to add resource: %v", err), http.StatusInternalServerError) + resources := createFn(req) + // Check if any resources were created + if len(resources) == 0 { + http.Error(w, "create function returned no resources", http.StatusInternalServerError) return } + + // --- FIX: Initialize array to store names --- + addedNames := make([]string, 0, len(resources)) + + for _, r := range resources { + if err := api.Manager.AddResourceToSnapshot(r, typ); err != nil { + http.Error(w, fmt.Sprintf("failed to add resource: %v", err), http.StatusInternalServerError) + return + } + + // --- FIX: Collect name during iteration --- + if nameable, ok := r.(interface{ GetName() string }); ok { + addedNames = append(addedNames, nameable.GetName()) + } + } + // Persist immediately using DeleteLogical (mark as disabled in DB) if err := api.Manager.FlushCacheToDB(context.Background(), storage.DeleteLogical); err != nil { http.Error(w, fmt.Sprintf("failed to persist resource to DB: %v", err), http.StatusInternalServerError) return } + // --- FIX: Encode the array of names in the response --- w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]string{"name": res.(interface{ GetName() string }).GetName()}) + response := map[string]interface{}{ + "status": "created", + "names": addedNames, + } + + // Fallback if no names were collected (e.g., resource type doesn't implement GetName()) + if len(addedNames) == 0 { + response["names"] = fmt.Sprintf("failed to collect names for %d resources", len(resources)) + } + + json.NewEncoder(w).Encode(response) } // disableResourceHandler handles disabling a resource (logical removal from cache/DB). @@ -304,6 +334,24 @@ w.WriteHeader(http.StatusOK) } +func (api *API) removeFilterChainHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req internalapi.RemoveFilterChainRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ListenerName == "" { + http.Error(w, "listener name required", http.StatusBadRequest) + return + } + ctx := context.Background() + if err := api.Manager.RemoveFilterChainFromListener(ctx, req.ListenerName, req.Domains); err != nil { + http.Error(w, fmt.Sprintf("failed to remove filter chain: %v", err), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) +} + // ---------------- Query / List Handlers ---------------- // listResourceHandler returns a list of enabled and disabled resources of a given type. diff --git a/internal/snapshot/resource_crud.go b/internal/snapshot/resource_crud.go index 3bcee29..35d24e6 100644 --- a/internal/snapshot/resource_crud.go +++ b/internal/snapshot/resource_crud.go @@ -207,6 +207,105 @@ return nil } +// RemoveFilterChainFromListener loads the current listener from the cache, removes the +// FilterChain that matches the provided ServerNames from the listener's list of FilterChains, +// and updates the cache with the new snapshot. +func (sm *SnapshotManager) RemoveFilterChainFromListener(ctx context.Context, listenerName string, serverNames []string) error { + log := internallog.LogFromContext(ctx) + + // 1. Validate input and get the current Listener from the cache + if len(serverNames) == 0 { + return fmt.Errorf("failed to get server names from filter chain") + } + + // Use ServerNames for matching, consistent with UpdateFilterChainOfListener + if len(serverNames) == 0 { + return fmt.Errorf("target filter chain match must specify at least one ServerName for targeted removal") + } + + resource, err := sm.GetResourceFromCache(listenerName, resourcev3.ListenerType) + if err != nil { + return fmt.Errorf("failed to get listener '%s' from cache: %w", listenerName, err) + } + + listener, ok := resource.(*listenerv3.Listener) + if !ok { + return fmt.Errorf("resource '%s' is not a Listener type", listenerName) + } + + // 2. Iterate and attempt to find and remove the matching filter chain + foundMatch := false + var updatedChains []*listenerv3.FilterChain // New slice for chains to keep + + for _, existingChain := range listener.FilterChains { + existingServerNames := existingChain.GetFilterChainMatch().GetServerNames() + + // Use the provided ServerNamesEqual for matching + if ServerNamesEqual(existingServerNames, serverNames) { + // Match found! DO NOT append this chain, effectively removing it. + foundMatch = true + log.Debugf("Removing filter chain with match: %v from listener '%s'", serverNames, listenerName) + continue + } + + // Keep the existing chain if it does not match + updatedChains = append(updatedChains, existingChain) + } + + // 3. Handle the result + if !foundMatch { + return fmt.Errorf("no existing filter chain found on listener '%s' with matching server names: %v", + listenerName, serverNames) + } + + // 4. Update the listener with the new slice of filter chains + listener.FilterChains = updatedChains + + // 5. Get current snapshot to extract all resources for the new snapshot + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("failed to get snapshot for modification: %w", err) + } + + resources := sm.getAllResourcesFromSnapshot(snap) + + // Replace the old listener with the modified one in the resource list + listenerList, ok := resources[resourcev3.ListenerType] + if !ok { + return fmt.Errorf("listener resource type not present in snapshot") + } + + foundAndReplaced := false + for i, res := range listenerList { + if namer, ok := res.(interface{ GetName() string }); ok && namer.GetName() == listenerName { + listenerList[i] = listener // Replace with the modified listener + foundAndReplaced = true + break + } + } + + if !foundAndReplaced { + // Should not happen if GetResourceFromCache succeeded. + return fmt.Errorf("failed to locate listener '%s' in current resource list for replacement during removal", listenerName) + } + + // 6. Create and set the new snapshot + version := fmt.Sprintf("listener-remove-chain-%s-%d", listenerName, time.Now().UnixNano()) + newSnap, err := cachev3.NewSnapshot(version, resources) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } + + if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { + return fmt.Errorf("failed to set new snapshot: %w", err) + } + // Assume FlushCacheToDB is a necessary final step after snapshot update + sm.FlushCacheToDB(ctx, storage.DeleteLogical) + log.Infof("Successfully removed filter chain (match: %v) from listener '%s'", serverNames, listenerName) + + return nil +} + // AddResourceToSnapshot adds any resource to the snapshot dynamically func (sm *SnapshotManager) AddResourceToSnapshot(resource types.Resource, typ resourcev3.Type) error { snap, err := sm.Cache.GetSnapshot(sm.NodeID) @@ -233,7 +332,7 @@ } newSnap, _ := cachev3.NewSnapshot( - "snap-generic-"+resourceNamer.GetName(), + "snap-generic-"+resourceNamer.GetName()+"-"+time.Now().Format(time.RFC3339), resources, ) return sm.Cache.SetSnapshot(context.TODO(), sm.NodeID, newSnap) diff --git a/internal/snapshot/resource_io.go b/internal/snapshot/resource_io.go index 3e5a090..6f4154a 100644 --- a/internal/snapshot/resource_io.go +++ b/internal/snapshot/resource_io.go @@ -41,56 +41,33 @@ return nil } -// LoadSnapshotFromFile reads a YAML/JSON file, parses it, and returns a map of xDS resources. -func LoadSnapshotFromFile(context context.Context, filePath string) (map[resourcev3.Type][]types.Resource, error) { - log := internallog.LogFromContext(context) - - // Read the file - data, err := os.ReadFile(filePath) - if err != nil { - return nil, fmt.Errorf("failed to read file: %w", err) +// LoadAndUnmarshal takes raw data (e.g., from a file or string) and unmarshals +// it into the target interface using YAML/JSON rules. +func LoadAndUnmarshal(data []byte, target interface{}) error { + if err := yaml.Unmarshal(data, target); err != nil { + return fmt.Errorf("failed to unmarshal data: %w", err) } + return nil +} - var raw interface{} - if err := yaml.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML/JSON file %s: %w", filePath, err) - } +// ProcessFn is a function type that defines the processing logic to be applied +// to a potential resource node found during the walk. +// It returns a non-nil error if processing fails and should stop the walk. +type ProcessFn func(ctx context.Context, node map[string]interface{}) error - resources := make(map[resourcev3.Type][]types.Resource) - +// WalkAndProcess traverses an arbitrary Go data structure (unmarshaled from YAML/JSON) +// and applies the provided ProcessFn to every map[string]interface{} node it finds. +func WalkAndProcess(ctx context.Context, raw interface{}, processFn ProcessFn) error { var walk func(node interface{}) error walk = func(node interface{}) error { switch v := node.(type) { case map[string]interface{}: - if typStr, ok := v["@type"].(string); ok { - typ := resourcev3.Type(typStr) - - // only process known top-level xDS resources - var resource types.Resource - var newResource bool - - switch typ { - case resourcev3.ClusterType: - resource = &clusterv3.Cluster{} - newResource = true - case resourcev3.ListenerType: - resource = &listenerv3.Listener{} - newResource = true - // ... other types ... - default: - log.Warnf("unsupported resource type: %s", typ) - // Skip nested or unsupported types - } - - if newResource { - if err := unmarshalYamlNodeToProto(v, resource); err != nil { - return fmt.Errorf("failed to unmarshal %s from file: %w", typ, err) - } - resources[typ] = append(resources[typ], resource) - } + // Apply the custom processing function to the current map node + if err := processFn(ctx, v); err != nil { + return err } - // recurse into children + // Recurse into children for _, child := range v { if err := walk(child); err != nil { return err @@ -107,13 +84,97 @@ return nil } - if err := walk(raw); err != nil { + return walk(raw) +} + +// makeResourceProcessor creates the specific ProcessFn needed for the xDS resource logic. +func makeResourceProcessor( + log internallog.Logger, + resources map[resourcev3.Type][]types.Resource, +) ProcessFn { + return func(_ context.Context, v map[string]interface{}) error { + if typStr, ok := v["@type"].(string); ok { + typ := resourcev3.Type(typStr) + + // only process known top-level xDS resources + var resource types.Resource + var newResource bool + + switch typ { + case resourcev3.ClusterType: + resource = &clusterv3.Cluster{} + newResource = true + case resourcev3.ListenerType: + resource = &listenerv3.Listener{} + newResource = true + // ... other types ... + default: + log.Warnf("unsupported resource type: %s", typ) + // Skip nested or unsupported types + } + + if newResource { + // NOTE: unmarshalYamlNodeToProto must be available in this scope + if err := unmarshalYamlNodeToProto(v, resource); err != nil { + return fmt.Errorf("failed to unmarshal %s from file: %w", typ, err) + } + resources[typ] = append(resources[typ], resource) + } + } + return nil + } +} + +// LoadSnapshotFromFile reads a YAML/JSON file, parses it, and returns a map of xDS resources. +func LoadSnapshotFromFile(context context.Context, filePath string) (map[resourcev3.Type][]types.Resource, error) { + log := internallog.LogFromContext(context) + + // Read the file (Step 1: Read) + data, err := os.ReadFile(filePath) + if err != nil { + return nil, fmt.Errorf("failed to read file: %w", err) + } + + // Unmarshal data (Step 2: Generic Unmarshal) + var raw interface{} + if err := LoadAndUnmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err) + } + + resources := make(map[resourcev3.Type][]types.Resource) + processor := makeResourceProcessor(log, resources) + + // Walk and Process (Step 3: Generic Walk with specific processor) + if err := WalkAndProcess(context, raw, processor); err != nil { return nil, err } return resources, nil } +// SaveSnapshotToFile marshals the current cache snapshot to JSON and writes it to a file. +func SaveSnapshotToFile(snap cache.ResourceSnapshot, filePath string) error { + + out := make(map[string][]interface{}) + + // Iterate over all known types + clusterTypeResources := snap.GetResources(resourcev3.ClusterType) + for _, r := range clusterTypeResources { + out[resourcev3.ClusterType] = append(out[resourcev3.ClusterType], r) + } + listenerTypeResources := snap.GetResources(resourcev3.ListenerType) + for _, r := range listenerTypeResources { + out[resourcev3.ListenerType] = append(out[resourcev3.ListenerType], r) + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + // LoadFilterChainFromYAML unmarshals a YAML string representing an Envoy Listener FilterChain // configuration into a listenerv3.FilterChain protobuf message using protojson pipeline. func LoadFilterChainFromYAML(ctx context.Context, yamlStr string) (*listenerv3.FilterChain, error) { @@ -144,85 +205,39 @@ return rawChain, nil } -// SaveSnapshotToFile marshals the current cache snapshot to JSON and writes it to a file. -func SaveSnapshotToFile(snap cache.ResourceSnapshot, filePath string) error { - - out := make(map[string][]interface{}) - - // Iterate over all known types - clusterTypeResources := snap.GetResources(resourcev3.ClusterType) - for _, r := range clusterTypeResources { - out[resourcev3.ClusterType] = append(out[resourcev3.ClusterType], r) - } - listenerTypeResources := snap.GetResources(resourcev3.ListenerType) - for _, r := range listenerTypeResources { - out[resourcev3.ListenerType] = append(out[resourcev3.ListenerType], r) - } - - data, err := json.MarshalIndent(out, "", " ") - if err != nil { - return err - } - - return os.WriteFile(filePath, data, 0644) -} - -// The package imports and existing helper functions (unmarshalYamlNodeToProto, LoadSnapshotFromFile, etc.) are assumed to be present. - -// LoadClusterFromYAML unmarshals a YAML string representing an Envoy Cluster -// configuration into a clusterv3.Cluster protobuf message. -func LoadClusterFromYAML(ctx context.Context, yamlStr string) (*clusterv3.Cluster, error) { +// LoadResourceFromYAML unmarshals a YAML string representing a single xDS resource +// (like a Cluster or a Listener) into the appropriate Protobuf message. +// +// It leverages the existing WalkAndProcess logic to find and unmarshal the resource. +func LoadResourceFromYAML(ctx context.Context, yamlStr string, expectedType resourcev3.Type) ([]types.Resource, error) { log := internallog.LogFromContext(ctx) // 1. Unmarshal YAML into a generic Go map - var rawClusterMap map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlStr), &rawClusterMap); err != nil { - log.Errorf("Failed to unmarshal YAML for Cluster: %v", err) - return nil, fmt.Errorf("failed to unmarshal YAML into generic map for Cluster: %w", err) - } - if rawClusterMap == nil { - return nil, fmt.Errorf("failed to unmarshal YAML: input for Cluster was empty or invalid") + var raw interface{} + if err := LoadAndUnmarshal([]byte(yamlStr), &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal YAML into generic structure: %w", err) } - // 2. Unmarshal the generic map into the Protobuf struct using the helper - cluster := &clusterv3.Cluster{} - if err := unmarshalYamlNodeToProto(rawClusterMap, cluster); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML into Cluster using protojson: %w", err) + // This map will hold the one resource we expect to find. + resources := make(map[resourcev3.Type][]types.Resource) + + // 2. Use the standard resource processor to walk the structure. + processor := makeResourceProcessor(log, resources) + + // 3. Walk and Process + if err := WalkAndProcess(ctx, raw, processor); err != nil { + return nil, fmt.Errorf("failed to walk and process YAML: %w", err) } - // Sanity check: ensure the cluster has a name - if cluster.Name == "" { - return nil, fmt.Errorf("cluster loaded but has an empty name") + // 4. Validate and return the resource + resourceList, ok := resources[expectedType] + if !ok || len(resourceList) == 0 { + // Only return an error if the expected type was not found. + return nil, fmt.Errorf("no resource of expected type %s found in YAML", expectedType) } - - return cluster, nil -} - -// LoadListenerFromYAML unmarshals a YAML string representing an Envoy Listener -// configuration into a listenerv3.Listener protobuf message. -func LoadListenerFromYAML(ctx context.Context, yamlStr string) (*listenerv3.Listener, error) { - log := internallog.LogFromContext(ctx) - - // 1. Unmarshal YAML into a generic Go map - var rawListenerMap map[string]interface{} - if err := yaml.Unmarshal([]byte(yamlStr), &rawListenerMap); err != nil { - log.Errorf("Failed to unmarshal YAML for Listener: %v", err) - return nil, fmt.Errorf("failed to unmarshal YAML into generic map for Listener: %w", err) + if len(resourceList) > 1 { + // Warn if multiple resources were found, but return the first one as expected. + log.Warnf("found %d resources of type %s, expected 1; returning the first", len(resourceList), expectedType) } - if rawListenerMap == nil { - return nil, fmt.Errorf("failed to unmarshal YAML: input for Listener was empty or invalid") - } - - // 2. Unmarshal the generic map into the Protobuf struct using the helper - listener := &listenerv3.Listener{} - if err := unmarshalYamlNodeToProto(rawListenerMap, listener); err != nil { - return nil, fmt.Errorf("failed to unmarshal YAML into Listener using protojson: %w", err) - } - - // Sanity check: ensure the listener has a name - if listener.Name == "" { - return nil, fmt.Errorf("listener loaded but has an empty name") - } - - return listener, nil + return resourceList, nil } diff --git a/static/clusters.js b/static/clusters.js index fce66a8..89c6fc3 100644 --- a/static/clusters.js +++ b/static/clusters.js @@ -1,13 +1,3 @@ -// clusters.js - -// Import general modal functions from modals.js -// import { showModal } from './modals.js'; - -// Import config-specific modal launchers from data_fetchers.js -import { showClusterConfigModal } from './data_fetchers.js'; -// We also need 'showModal' if it's used directly in clusters.js for error handling. -// If it is, import it from modals.js: -import { showModal } from './modals.js'; import {API_BASE_URL, configStore} from './global.js' // ========================================================================= // CLUSTER UTILITIES diff --git a/static/data_fetchers.js b/static/data_fetchers.js index db0193c..1f66740 100644 --- a/static/data_fetchers.js +++ b/static/data_fetchers.js @@ -1,6 +1,6 @@ // data_fetchers.js import { API_BASE_URL, configStore } from './global.js'; -import { showModal, switchTab } from './modals.js'; +import { showConfigModal, switchTab } from './modals.js'; import { listListeners } from './listeners.js'; // Will be imported later // ========================================================================= @@ -89,7 +89,7 @@ if (!jsonData) { const errorMsg = 'Filter Chain configuration not found in memory.'; console.error(errorMsg); - showModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); + showConfigModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); return; } @@ -120,14 +120,14 @@ defaultTab = 'json'; // Switch to JSON tab if YAML generation failed } - showModal(title, jsonData, yamlData, defaultTab); + showConfigModal(title, jsonData, yamlData, defaultTab); } export async function showClusterConfigModal(clusterName) { const config = configStore.clusters[clusterName]; if (!config) { - showModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); + showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); return; } @@ -148,10 +148,35 @@ } } - showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); + showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); } +export async function showListenerConfigModal(listenerName) { + const config = configStore.listeners[listenerName]; + if (!config) { + showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); + return; + } + let yamlData = configStore.listeners[listenerName]?.yaml || 'Loading YAML...'; + + if (yamlData === 'Loading YAML...') { + try { + const response = await fetch(`${API_BASE_URL}/get-listener?name=${listenerName}&format=yaml`); + if (!response.ok) { + yamlData = `Error fetching YAML: ${response.status} ${response.statusText}`; + } else { + yamlData = await response.text(); + configStore.listeners[listenerName].yaml = yamlData; // Store YAML + } + } catch (error) { + console.error("Failed to fetch YAML listener config:", error); + yamlData = `Network Error fetching YAML: ${error.message}`; + } + } + + showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); +} // ========================================================================= // FILTER CHAIN ADDITION LOGIC (NEW) diff --git a/static/global.js b/static/global.js index 80b1944..79a1772 100644 --- a/static/global.js +++ b/static/global.js @@ -26,7 +26,7 @@ * @param {object} jsonData - The configuration data object (used for JSON tab). * @param {string} yamlData - The configuration data as a YAML string. */ -function showModal(title, jsonData, yamlData) { +function showConfigModal(title, jsonData, yamlData) { document.getElementById('modal-title').textContent = title; // Populate JSON content @@ -132,7 +132,7 @@ if (!jsonData) { const errorMsg = 'Filter Chain configuration not found in memory.'; console.error(errorMsg); - showModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); + showConfigModal(`🚨 Error: ${title}`, { error: errorMsg }, errorMsg); return; } @@ -180,7 +180,7 @@ // The previous code: yamlData = extractFilterChainByDomain(yamlData, domainName) || yamlData; // is no longer needed. - showModal(title, jsonData, yamlData); + showConfigModal(title, jsonData, yamlData); } @@ -255,7 +255,7 @@ async function showClusterConfigModal(clusterName) { const config = configStore.clusters[clusterName]; if (!config) { - showModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); + showConfigModal(`🚨 Error: Cluster Not Found`, { name: clusterName, error: 'Configuration data missing from memory.' }, 'Error: Cluster not in memory.'); return; } @@ -278,13 +278,13 @@ } // Pass JSON object from memory and authoritative YAML from API/memory - showModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); + showConfigModal(`Full Config for Cluster: ${clusterName}`, config, yamlData); } async function showListenerConfigModal(listenerName) { const config = configStore.listeners[listenerName]; if (!config) { - showModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); + showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); return; } @@ -307,7 +307,7 @@ } // Pass JSON object from memory and authoritative YAML from API/memory - showModal(`Full Config for Listener: ${listenerName}`, config, yamlData); + showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); } // ========================================================================= diff --git a/static/index.html b/static/index.html index a01ddf7..689adc3 100644 --- a/static/index.html +++ b/static/index.html @@ -26,6 +26,7 @@ +

Existing Clusters (Click a row for full JSON)

@@ -111,7 +112,7 @@ - + `; }); @@ -97,6 +121,8 @@ tableBody.innerHTML = 'No listeners found.'; configStore.listeners = {}; + // Clear temporary domain storage too + configStore.filterChainDomains = {}; return; } @@ -110,6 +136,10 @@ }; return acc; }, configStore.listeners); + + // Clear temporary domain storage before generating new data + configStore.filterChainDomains = {}; + tableBody.innerHTML = ''; allListeners.forEach(listener => { @@ -149,7 +179,7 @@ } // ========================================================================= -// LISTENER ENABLE/DISABLE/REMOVE LOGIC +// CORE API LOGIC // ========================================================================= async function toggleListenerStatus(listenerName, action) { @@ -182,7 +212,108 @@ } } -// Exported functions must be attached to 'window' if called from inline HTML attributes +/** + * Removes a specific filter chain from a listener based on its domains. + * @param {string} listenerName - The name of the listener. + * @param {string[]} domains - An array of domain names that identify the filter chain. + * @param {Event} event - The click event to stop propagation. + */ +export async function removeFilterChain(listenerName, domains, event) { + // Note: The event is passed but only used here for stopPropagation. + // It is not strictly needed for the API call itself. + if (event) event.stopPropagation(); + + const domainList = domains.join(', '); + if (domains.length === 1 && domains[0] === '(default)') { + // Special handling for default chain + if (!confirm(`⚠️ WARNING: You are about to remove the DEFAULT filter chain for listener: ${listenerName}. This will likely break the listener. Continue?`)) { + return; + } + } else if (!confirm(`Are you sure you want to REMOVE the filter chain for domains: ${domainList} on listener: ${listenerName}?`)) { + return; + } + + const url = `${API_BASE_URL}/remove-filter-chain`; + + const payload = { + listener_name: listenerName, + domains: domains + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HTTP Error ${response.status}: ${errorBody}`); + } + + console.log(`Filter chain for domains '${domainList}' on listener '${listenerName}' successfully removed.`); + // Reload the listener list to refresh the UI + listListeners(); + } catch (error) { + console.error(`Failed to remove filter chain for '${domainList}' on listener '${listenerName}':`, error); + alert(`Failed to remove filter chain for '${domainList}' on listener '${listenerName}'. Check console for details.`); + } +} + +/** + * Submits the new listener YAML to the /add-listener endpoint. (NEW FUNCTION) + */ +export async function submitNewListener() { + const yamlInput = document.getElementById('add-listener-yaml-input'); + const listenerYaml = yamlInput.value.trim(); + + if (!listenerYaml) { + alert('Please paste the Listener YAML configuration.'); + return; + } + + try { + // Simple YAML validation is assumed to be handled by js-yaml globally + // const parsedJson = jsyaml.load(listenerYaml); // This line is for optional client-side parsing/validation + + const payload = { + yaml: listenerYaml + }; + const url = `${API_BASE_URL}/add-listener`; + + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`HTTP Error ${response.status}: ${errorBody}`); + } + + console.log(`New listener successfully added.`); + alert('Listener successfully added! The dashboard will now refresh.'); + + // Clear input and hide modal + yamlInput.value = ''; + hideAddListenerModal(); + + // Reload the listener list to refresh the UI + listListeners(); + + } catch (error) { + console.error(`Failed to add new listener:`, error); + alert(`Failed to add new listener. Check console for details. Error: ${error.message}`); + } +} + + +// ========================================================================= +// UI EXPORTED FUNCTIONS +// ========================================================================= + export function disableListener(listenerName, event) { event.stopPropagation(); if (confirm(`Are you sure you want to DISABLE listener: ${listenerName}?`)) { @@ -202,4 +333,64 @@ if (confirm(`⚠️ WARNING: Are you absolutely sure you want to PERMANENTLY REMOVE listener: ${listenerName}? This action cannot be undone.`)) { toggleListenerStatus(listenerName, 'remove'); } -} \ No newline at end of file +} + +/** + * UI entrypoint for removing a filter chain using a memory key. + * @param {string} listenerName - The name of the listener. + * @param {string} memoryKey - The key used to retrieve the domains array from configStore. + * @param {Event} event - The click event to stop propagation. + */ +export function removeFilterChainByRef(listenerName, memoryKey, event) { + event.stopPropagation(); + + const domains = configStore.filterChainDomains?.[memoryKey]; + + if (!domains) { + console.error(`Error: Could not find domains array in memory for key: ${memoryKey}`); + alert('Failed to find filter chain configuration in memory. Please refresh the page and try again.'); + return; + } + + // Call the core logic with the retrieved array + removeFilterChain(listenerName, domains, event); +} + +/** + * Shows the modal for adding a new full listener. (NEW FUNCTION) + */ +export function showAddListenerModal() { + // You must call showModal with the correct ID: 'addListenerModal' + showModal('addListenerModal'); +} + +/** + * Hides the modal for adding a new full listener. (NEW FUNCTION) + */ +export function hideAddListenerModal() { + const modal = document.getElementById('addListenerModal'); + if (modal) { + modal.style.display = 'none'; + // Clear the input when closing + document.getElementById('add-listener-yaml-input').value = ''; + } +} + + +// ========================================================================= +// ATTACH TO WINDOW +// Exported functions must be attached to 'window' if called from inline HTML attributes +// ========================================================================= + +window.removeFilterChainByRef = removeFilterChainByRef; +window.disableListener = disableListener; +window.enableListener = enableListener; +window.removeListener = removeListener; +window.showAddFilterChainModal = showAddFilterChainModal; +window.showListenerConfigModal = showListenerConfigModal; +window.showDomainConfig = showDomainConfig; + +// NEW FUNCTIONS ATTACHED TO WINDOW +window.submitNewListener = submitNewListener; +window.showAddListenerModal = showAddListenerModal; +window.hideAddListenerModal = hideAddListenerModal; \ No newline at end of file diff --git a/static/modals.js b/static/modals.js index 52873a5..4e3a2f8 100644 --- a/static/modals.js +++ b/static/modals.js @@ -4,11 +4,38 @@ import { configStore, API_BASE_URL } from './global.js'; // ========================================================================= -// MAIN CONFIGURATION MODAL HANDLERS +// GENERIC MODAL HANDLERS // ========================================================================= /** - * Switches between the JSON and YAML tabs in a given modal content area. + * Shows any modal element by its ID. (NEW GENERIC FUNCTION) + * @param {string} modalId - The ID of the modal element to display. + */ +export function showModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = 'block'; + } +} + +/** + * Hides any modal element by its ID. (NEW GENERIC FUNCTION) + * @param {string} modalId - The ID of the modal element to hide. Defaults to 'configModal'. + */ +export function hideModal(modalId = 'configModal') { + const modal = document.getElementById(modalId); + if (modal) { + modal.style.display = 'none'; + } +} + + +// ========================================================================= +// CONFIGURATION DISPLAY MODAL HANDLERS (JSON/YAML tabs) +// ========================================================================= + +/** + * Switches between the JSON and YAML tabs in the main config modal. * This function MUST be exported as it's used directly in HTML/inline handlers. * @param {HTMLElement} modalContent - The parent container (modal-content) for tabs. * @param {string} tabName - 'json' or 'yaml'. @@ -27,13 +54,13 @@ } /** - * Displays the main configuration modal. + * Displays the main configuration modal (with JSON/YAML tabs). (RENAMED FROM showModal) * @param {string} title - The modal title. * @param {object} jsonData - The configuration data object (for JSON tab). * @param {string} yamlData - The configuration data as a YAML string. * @param {string} [defaultTab='yaml'] - The tab to show by default. */ -export function showModal(title, jsonData, yamlData, defaultTab = 'yaml') { +export function showConfigModal(title, jsonData, yamlData, defaultTab = 'yaml') { document.getElementById('modal-title').textContent = title; // Populate JSON content @@ -49,11 +76,15 @@ switchTab(modalContent, defaultTab); } - document.getElementById('configModal').style.display = 'block'; + // Use generic showModal + showModal('configModal'); } -export function hideModal() { - document.getElementById('configModal').style.display = 'none'; +/** + * Hides the main configuration modal (configModal). (Uses generic hideModal) + */ +export function hideConfigModal() { + hideModal('configModal'); } /** @@ -91,8 +122,8 @@ const yamlInput = document.getElementById('add-fc-yaml-input'); yamlInput.value = ''; - // 4. Show the modal - document.getElementById('addFilterChainModal').style.display = 'block'; + // 4. Show the modal (using generic showModal) + showModal('addFilterChainModal'); // 5. Provide a template to guide the user (optional) yamlInput.placeholder = @@ -104,10 +135,10 @@ } /** - * Closes the Add Filter Chain modal. + * Closes the Add Filter Chain modal. (Uses generic hideModal) */ export function hideAddFilterChainModal() { - document.getElementById('addFilterChainModal').style.display = 'none'; + hideModal('addFilterChainModal'); } @@ -132,11 +163,12 @@ document.getElementById('db-only-data').textContent = JSON.stringify(dbOnly, null, 2); - document.getElementById('consistencyModal').style.display = 'block'; + // Use generic showModal + showModal('consistencyModal'); } export function hideConsistencyModal() { - document.getElementById('consistencyModal').style.display = 'none'; + hideModal('consistencyModal'); } @@ -147,27 +179,37 @@ window.addEventListener('keydown', (event) => { // Check for Escape key to close all modals if (event.key === 'Escape') { - hideModal(); + hideConfigModal(); hideAddFilterChainModal(); hideConsistencyModal(); + // Assume hideAddListenerModal is also attached to window/global scope if not in modals.js + // If it is in listeners.js and attached to window: window.hideAddListenerModal(); } }); // Close modal when clicking outside of the content (on the backdrop) window.addEventListener('click', (event) => { const modal = document.getElementById('configModal'); - const addModal = document.getElementById('addFilterChainModal'); + const addFCModal = document.getElementById('addFilterChainModal'); const consistencyModal = document.getElementById('consistencyModal'); - + const addListenerModal = document.getElementById('addListenerModal'); // NEW + if (event.target === modal) { - hideModal(); + hideConfigModal(); } - if (event.target === addModal) { + if (event.target === addFCModal) { hideAddFilterChainModal(); } if (event.target === consistencyModal) { hideConsistencyModal(); } + // Added for the new listener modal + if (event.target === addListenerModal) { + // If hideAddListenerModal is in listeners.js, you must call it from window + // window.hideAddListenerModal(); + // OR, if you decide to move it here: + hideModal('addListenerModal'); + } }); // ========================================================================= @@ -198,10 +240,13 @@ URL.revokeObjectURL(url); } +/** + * Fetches and displays the full configuration for a listener in the tabbed modal. + */ export async function showListenerConfigModal(listenerName) { const config = configStore.listeners[listenerName]; if (!config) { - showModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); + showConfigModal(`🚨 Error: Listener Not Found`, { name: listenerName, error: 'Configuration data missing from memory.' }, 'Error: Listener not in memory.'); return; } @@ -222,5 +267,6 @@ } } - showModal(`Full Config for Listener: ${listenerName}`, config, yamlData); + // Use the renamed function + showConfigModal(`Full Config for Listener: ${listenerName}`, config, yamlData); } \ No newline at end of file diff --git a/static/style.css b/static/style.css index 68635f2..f235de0 100644 --- a/static/style.css +++ b/static/style.css @@ -5,9 +5,10 @@ --success-color: #198754; /* New: for consistent status */ --danger-color: #dc3545; /* New: for conflict status */ --warning-color: #ffc107; /* New: for error status */ - --border-color: #dee2e6; + --border-color: #e9ecef; /* MODIFIED: Lighter border for a softer look */ --bg-color-light: #f8f9fa; --text-color: #212529; + --text-color-light: #495057; /* NEW: for secondary text/headers */ /* Light Theme Code Colors */ --code-bg: #f8f8f8; /* Very light gray, like a parchment or fresh terminal */ @@ -30,37 +31,59 @@ background: #fff; border: 1px solid var(--border-color); padding: 30px; - border-radius: 8px; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.08); + border-radius: 12px; /* MODIFIED: Larger radius for a modern feel */ + box-shadow: 0 6px 15px rgba(0, 0, 0, 0.05); /* MODIFIED: Softer, deeper shadow */ position: relative; } +/* NEW: Consistent Heading Styles */ +h1 { + font-size: 2rem; + font-weight: 700; + margin-top: 0; + margin-bottom: 5px; +} + +h2 { + font-size: 1.5rem; + font-weight: 600; + margin-top: 30px; + margin-bottom: 10px; + color: var(--text-color-light); /* Subdued color for section headers */ +} + /* ------------------------------------------------------------- */ -/* NEW: Action Buttons (Enable/Disable/Toggle in table) */ +/* Action Buttons (General Styles) */ /* ------------------------------------------------------------- */ .action-button { - font-size: 0.85rem; - padding: 5px 10px; + font-size: 0.8rem; /* MODIFIED: Slightly smaller in the table for a cleaner fit */ + padding: 4px 8px; /* MODIFIED: Tighter padding */ border-radius: 4px; cursor: pointer; border: 1px solid transparent; transition: all 0.2s ease; margin: 0; - margin-right: 5px; /* Added spacing between buttons */ + margin-right: 5px; + min-width: 65px; /* NEW: Ensure uniform width in table */ + text-align: center; } .action-button.disable { - background-color: var(--secondary-color); - color: white; + background-color: #fff; /* MODIFIED: Ghost/Outlined style for better contrast */ + color: var(--secondary-color); + border-color: #dcdcdc; /* MODIFIED: Light gray border */ + font-weight: 500; } .action-button.disable:hover { - background-color: #5c636a; + background-color: var(--secondary-color); /* Solid on hover */ + color: white; } .action-button.enable { background-color: var(--success-color); color: white; + font-weight: 500; } .action-button.enable:hover { @@ -76,17 +99,17 @@ background-color: #bb2d3b; } -/* NEW: Add Chain Button */ +/* Add Chain Button */ .action-button.add { - background-color: #28a745; /* Darker green for "Add" */ + background-color: var(--success-color); /* MODIFIED: Using variable for consistency */ color: white; } .action-button.add:hover { - background-color: #1e7e34; + background-color: #157347; } -/* NEW: Cancel button for forms (like in the Add Chain modal) */ +/* Cancel button for forms (like in the Add Chain modal) */ .action-button.cancel { background-color: var(--secondary-color); color: white; @@ -96,7 +119,7 @@ } /* ------------------------------------------------------------- */ -/* NEW: Consistency Status Indicator */ +/* Consistency Status Indicator (Modernized) */ /* ------------------------------------------------------------- */ #consistency-status-container { position: absolute; @@ -106,14 +129,15 @@ } #consistency-button { - font-size: 0.9rem; - padding: 8px 15px; - border-radius: 5px; - font-weight: 600; + font-size: 0.85rem; /* MODIFIED: Smaller font */ + padding: 5px 12px; /* MODIFIED: Tighter padding */ + border-radius: 20px; /* MODIFIED: Pill-shaped */ + font-weight: 700; cursor: default; pointer-events: none; transition: background-color 0.2s ease, transform 0.1s ease; border: none; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* NEW: Subtle shadow */ } #consistency-button.consistent { @@ -142,85 +166,118 @@ opacity: 0.8; } -/* Toolbar */ +/* Toolbar (Modernized) */ .toolbar { display: flex; - justify-content: flex-end; + justify-content: flex-start; /* MODIFIED: Align left for better flow */ gap: 10px; - margin-bottom: 15px; - margin-top: 15px; + margin-bottom: 25px; /* MODIFIED: More separation */ + margin-top: 25px; /* MODIFIED: More separation */ + padding: 10px 0; + border-top: 1px solid var(--border-color); /* NEW: Subtle separators */ + border-bottom: 1px solid var(--border-color); } .toolbar button { - background-color: var(--primary-color); - color: white; - border: none; + background-color: #fff; /* MODIFIED: Ghost/Outlined button style */ + color: var(--primary-color); + border: 1px solid var(--primary-color); border-radius: 5px; - padding: 6px 12px; + padding: 8px 14px; /* MODIFIED: Slightly more padding */ font-size: 0.9rem; + font-weight: 500; cursor: pointer; - transition: background-color 0.2s ease; + transition: all 0.2s ease; } .toolbar button:hover { - background-color: #0b5ed7; + background-color: var(--primary-color); + color: white; /* Text inverts to white on hover */ } -/* Table */ +/* Primary Action Button (Add New Listener) */ +.toolbar button:last-child { + background-color: var(--primary-color); + color: white; + margin-left: auto; /* Push to the right */ + border-color: var(--primary-color); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +.toolbar button:last-child:hover { + background-color: #0b5ed7; + border-color: #0b5ed7; +} + + +/* Table (Modernized - Striped and Cleaner) */ .config-table { width: 100%; - border-collapse: collapse; + border-collapse: separate; /* MODIFIED: Allows border-radius on cells */ + border-spacing: 0; /* NEW: Removes spacing between borders */ margin-top: 15px; + border: 1px solid var(--border-color); /* NEW: Single border around the table */ + border-radius: 6px; /* NEW: Rounded corners on table */ + overflow: hidden; } .config-table th, .config-table td { - padding: 10px 12px; + padding: 12px 15px; /* MODIFIED: Increased vertical padding */ text-align: left; - border-bottom: 1px solid var(--border-color); - vertical-align: top; + border-bottom: none; /* MODIFIED: Removed internal horizontal lines */ + vertical-align: middle; /* MODIFIED: Center align content vertically */ } .config-table th { - background-color: #f1f3f5; + background-color: var(--bg-color-light); /* MODIFIED: Lighter header background */ font-weight: 600; - color: #495057; + color: var(--text-color-light); /* MODIFIED: Subdued header text */ position: sticky; top: 0; z-index: 2; + border-bottom: 1px solid var(--border-color); /* Re-add separator */ } -/* Prevent whole row hover highlight */ +/* NEW: Zebra Striping */ +.config-table tbody tr:nth-child(even) { + background-color: #fcfcfd; +} +.config-table tbody tr:nth-child(odd) { + background-color: #fff; +} + +/* Prevent whole row hover highlight (still needed) */ .config-table tr:hover { background-color: transparent; } -/* Row-specific hover highlight */ +/* Row-specific hover highlight (Softer blue on hover) */ .config-table tr:hover:not(.disabled-row) { - background-color: #f8fbff; + background-color: #eef5ff; /* MODIFIED: Softer hover color */ } -.listener-name { - font-weight: 600; - color: var(--primary-color); - cursor: pointer; +/* Status Column Text Styling for readability */ +.config-table td:nth-child(2) { + font-family: monospace; + font-size: 0.875rem; + color: var(--text-color-light); } -/* Clickable link style */ .listener-name, .domain-name-link { font-weight: 600; color: var(--primary-color); cursor: pointer; - text-decoration: none; + text-decoration: none; /* MODIFIED: Remove default underline */ transition: color 0.15s ease; } /* Highlight only when hovering over the link itself */ .listener-name:hover, .domain-name-link:hover { - text-decoration: underline; - color: #0a58ca; + text-decoration: underline; /* MODIFIED: Add underline only on hover */ + color: #0b5ed7; } /* Keep non-clickable text normal */ @@ -229,8 +286,8 @@ } .disabled-row { - opacity: 0.6; - background-color: #f1f1f1; + opacity: 0.5; /* MODIFIED: Slightly more muted */ + background-color: #fbfbfb !important; } .disabled-row .listener-name { @@ -294,14 +351,58 @@ text-decoration: underline; } +/* IMPROVED: Layout for Route Type and Remove Button */ .route-type-display { font-size: 0.75rem; color: var(--secondary-color); margin-top: 3px; + + /* NEW: Use flex to align type and button on the same line */ + display: flex; + justify-content: space-between; /* Push button to the right */ + align-items: center; } +/* NEW: Style for the "Remove Chain" button inside the domain item */ +.action-button.remove-chain-button { + /* Override standard action button size */ + font-size: 0.7rem; + padding: 2px 6px; + border-radius: 3px; + margin: 0; + + /* Make it less aggressive than the full listener remove button */ + background-color: #ff4d4d; /* A lighter, softer red */ + color: white; + border: 1px solid #ff4d4d; + transition: all 0.2s ease; +} + +.action-button.remove-chain-button:hover { + background-color: var(--danger-color); /* Full red on hover */ + border-color: var(--danger-color); +} + +/* IMPROVED: Action column styling to stack buttons neatly (for the last column) */ +.config-table td:last-child { + display: flex; + flex-direction: column; /* Stack buttons vertically */ + gap: 4px; /* Small space between stacked buttons */ + /* Ensure padding is kept */ + padding: 10px 12px; +} + +/* Target buttons in the main Action column to ensure consistent width */ +.config-table td:last-child .action-button { + margin-right: 0; /* Remove horizontal margin */ + width: 100%; /* Make them full width for that column */ + box-sizing: border-box; + text-align: center; +} + + /* ============================================================= */ -/* MODAL FIXES AND TAB STYLING */ +/* MODAL FIXES AND TAB STYLING (Consistent) */ /* ============================================================= */ .modal { @@ -330,10 +431,18 @@ overflow-y: auto; } +/* NEW: Consistent Modal Header */ +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 10px; +} + /* Close Button Styling and Positioning Fix */ -.close-button { /* Use .close-button to match JS */ +.close-btn, /* The one used in the secondary modals */ +.modal-content > .close { /* The one used in the main configModal (Unified) */ color: #aaa; - float: right; font-size: 28px; font-weight: bold; line-height: 1; @@ -344,21 +453,24 @@ z-index: 10; } -.close-button:hover, -.close-button:focus { +.close-btn:hover, +.close-btn:focus, +.modal-content > .close:hover, +.modal-content > .close:focus { color: #000; text-decoration: none; } -.modal-content h2 { +.modal-content h2, +.modal-content h3 { /* Apply to H2 and H3 for consistency */ padding-right: 40px; + margin-top: 0; /* Ensures consistent top margin on modal headers */ } /* Tab Controls Styling */ -/* Tab Controls Styling - UPDATED */ .tab-controls { - display: flex; /* Enable flexbox */ - align-items: flex-end; /* Align tabs to the bottom of the container */ + display: flex; + align-items: flex-end; margin: 15px 0 0; border-bottom: 2px solid var(--border-color); } @@ -381,38 +493,31 @@ border-bottom-color: var(--primary-color); } -/* NEW: Download Button Styling */ +/* Download Button Styling */ .download-button { - /* Push this button to the far right by consuming all available space to its left */ margin-left: auto; - - /* Make the download button look like a primary action button, not a tab */ background-color: var(--primary-color); color: white; border-radius: 4px; - - /* Override tab-button border */ border-bottom: none !important; - - /* Adjust padding to be more like a traditional button */ padding: 8px 15px; font-size: 0.9rem; font-weight: 500; } .download-button:hover { - background-color: #0b5ed7; /* Darker primary color on hover */ + background-color: #0b5ed7; } -/* NEW: Light Theme Code Block Styling (applies to both JSON and YAML) */ +/* Light Theme Code Block Styling (applies to both JSON and YAML) */ .code-block { - background: var(--code-bg); /* Very light gray */ - color: var(--code-text); /* Dark text */ + background: var(--code-bg); + color: var(--code-text); font-family: monospace; font-size: 0.9rem; padding: 15px; border-radius: 4px; - border: 1px solid var(--code-border); /* Light border */ + border: 1px solid var(--code-border); max-height: 500px; overflow: auto; white-space: pre; @@ -420,7 +525,7 @@ } /* ------------------------------------------------------------- */ -/* NEW: Add Filter Chain Modal Styles */ +/* Add Filter Chain & Add Listener Modal Styles (Unified) */ /* ------------------------------------------------------------- */ /* Styles for the larger content area within the modal */ @@ -429,8 +534,9 @@ width: 90%; } -/* Styles for the YAML textarea input */ -#add-fc-yaml-input { +/* Styles for the YAML textarea input (Unified for all YAML inputs) */ +#add-fc-yaml-input, +#add-listener-yaml-input { /* Unified ID for consistency */ width: 100%; font-family: monospace; font-size: 0.9rem; @@ -440,11 +546,13 @@ background-color: var(--code-bg); color: var(--code-text); resize: vertical; - box-sizing: border-box; /* Ensure padding/border are included in the width */ + box-sizing: border-box; + min-height: 250px; /* Ensures Add Listener textarea has a good starting height */ } -/* Styles for the label above the textarea */ -label[for="add-fc-yaml-input"] { +/* Styles for the label above the textarea (Unified for all form labels) */ +label[for="add-fc-yaml-input"], +label[for="add-listener-yaml-input"] { /* Unified ID for consistency */ display: block; font-weight: 600; margin-bottom: 5px; @@ -461,8 +569,10 @@ /* ------------------------------------------------------------- */ -/* Consistency Modal Specific Styles (using light code theme) */ +/* Consistency Modal Specific Styles (Enhanced) */ /* ------------------------------------------------------------- */ + +/* Modal Action buttons container (Unified Footer) */ .modal-actions { display: flex; justify-content: flex-end; @@ -477,6 +587,7 @@ padding: 10px 20px; line-height: 1.2; text-align: center; + min-width: 150px; /* Consistent minimum width for action buttons */ } .modal-actions small { @@ -500,34 +611,44 @@ word-wrap: normal; } -/* UI Improvement: Conflict List styling (for JS-rendered content) */ +/* Consistency Conflict List styling (Enhanced) */ .conflict-list { list-style: none; padding: 0; margin: 10px 0 20px 0; + border: 1px solid var(--code-border); + border-radius: 4px; + background-color: var(--code-bg); + max-height: 200px; + overflow-y: auto; } .conflict-list li { padding: 8px 10px; - border-bottom: 1px solid #eee; + border-bottom: 1px solid var(--code-border); font-size: 0.95rem; display: flex; gap: 10px; align-items: center; } +.conflict-list li:last-child { + border-bottom: none; +} + .conflict-list li:nth-child(even) { - background-color: #fcfcfc; + background-color: transparent; } .conflict-list .no-conflicts { color: var(--success-color); font-weight: 500; - background-color: var(--bg-color-light); + background-color: #e6f7ef; border-radius: 4px; padding: 10px; - margin: 5px 0; + margin: 5px; border: 1px solid rgba(25, 135, 84, 0.2); + text-align: center; } .conflict-type { @@ -541,7 +662,8 @@ .conflict-name { font-family: monospace; font-weight: 500; - color: #343a40; + color: var(--text-color); + flex-grow: 1; } /* ------------------------------------------------------------- */