1
0
mirror of https://github.com/containous/traefik.git synced 2025-11-24 08:23:52 +03:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Gina A.
8bdcd72042 Web UI dashboard improvements 2025-11-21 09:00:05 +01:00
kevinpollet
2ad42cd0ec Merge branch v3.6 into master 2025-11-07 16:47:21 +01:00
50 changed files with 792 additions and 1049 deletions

View File

@@ -239,8 +239,6 @@ linters:
text: ' always receives '
linters:
- unparam
- path: pkg/server/service/bufferpool.go
text: 'SA6002: argument should be pointer-like to avoid allocations'
- path: pkg/server/middleware/middlewares.go
text: Function 'buildConstructor' has too many statements
linters:
@@ -316,8 +314,12 @@ linters:
text: 'the methods of "wasmMiddlewareBuilder" use pointer receiver and non-pointer receiver.'
linters:
- recvcheck
- path: pkg/server/service/bufferpool.go
text: 'SA6002: argument should be pointer-like to avoid allocations'
- path: pkg/proxy/httputil/bufferpool.go
text: 'SA6002: argument should be pointer-like to avoid allocations'
- path: pkg/udp/conn.go
text: 'SA6002: argument should be pointer-like to avoid allocations'
- path: integration/integration_test.go
text: 'var (gatewayAPIConformanceRunTest|traefikVersion) is unused'
- path: pkg/server/router/router.go

View File

@@ -1,29 +1,3 @@
## [v3.6.2](https://github.com/traefik/traefik/tree/v3.6.2) (2025-11-18)
[All Commits](https://github.com/traefik/traefik/compare/v3.6.1...v3.6.2)
**Bug fixes:**
- **[k8s/ingress-nginx]** Deprecate Kubernetes Ingress NGINX provider experimental flag ([#12286](https://github.com/traefik/traefik/pull/12286) by [rtribotte](https://github.com/rtribotte))
## [v3.6.1](https://github.com/traefik/traefik/tree/v3.6.1) (2025-11-13)
[All Commits](https://github.com/traefik/traefik/compare/v3.6.0...v3.6.1)
**Bug fixes:**
- **[docker]** Auto-negotiate Docker API Version ([#12256](https://github.com/traefik/traefik/pull/12256) by [felixbuenemann](https://github.com/felixbuenemann))
- **[server]** Fix multi-layer routing with models ([#12258](https://github.com/traefik/traefik/pull/12258) by [juliens](https://github.com/juliens))
- **[udp]** Revert "Avoid allocations in readLoop by using sync.Pool" ([#12267](https://github.com/traefik/traefik/pull/12267) by [kevinpollet](https://github.com/kevinpollet))
- **[webui]** Fix blocked navigation on Safari ([#12231](https://github.com/traefik/traefik/pull/12231) by [gndz07](https://github.com/gndz07))
- **[webui]** Restore remote Upgrade to Hub button web component ([#12219](https://github.com/traefik/traefik/pull/12219) by [gndz07](https://github.com/gndz07))
**Documentation:**
- **[k8s]** Fix Nginx provider documentation ([#12266](https://github.com/traefik/traefik/pull/12266) by [nmengin](https://github.com/nmengin))
- **[k8s]** Fix Gateway API version and the list of features supported ([#12254](https://github.com/traefik/traefik/pull/12254) by [nmengin](https://github.com/nmengin))
## [v2.11.31](https://github.com/traefik/traefik/tree/v2.11.31) (2025-11-13)
[All Commits](https://github.com/traefik/traefik/compare/v2.11.30...v2.11.31)
**Bug fixes:**
- **[docker,docker/swarm]** Auto-negotiate Docker API version ([#12262](https://github.com/traefik/traefik/pull/12262) by [kevinpollet](https://github.com/kevinpollet))
## [v3.6.0](https://github.com/traefik/traefik/tree/v3.6.0) (2025-11-07)
[All Commits](https://github.com/traefik/traefik/compare/v3.5.0-rc1...v3.6.0)

View File

@@ -525,32 +525,3 @@ To use the new `leasttime` load-balancer algorithm with the Kubernetes CRD provi
```shell
kubectl apply -f https://raw.githubusercontent.com/traefik/traefik/v3.6/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml
```
## v3.6.2
### Ingress NGINX Provider
The KubernetesIngressNGINX Provider is no longer experimental in v3.6.2 and can be enabled without the `experimental.kubernetesIngressNGINX` option.
**Deprecated Configuration:**
??? example "Experimental kubernetesIngressNGINX option (deprecated)"
```yaml tab="File (YAML)"
experimental:
kubernetesIngressNGINX: true
```
```toml tab="File (TOML)"
[experimental]
kubernetesIngressNGINX=true
```
```bash tab="CLI"
--experimental.kubernetesIngressNGINX=true
```
**Migration Steps:**
1. Remove the `kubernetesIngressNGINX` option from the experimental section
2. Configure the provider using the [kubernetesIngressNGINX Provider documentation](../reference/install-configuration/providers/kubernetes/kubernetes-ingress-nginx.md)

View File

@@ -10,7 +10,7 @@ specification from the Kubernetes Special Interest Groups (SIGs).
This provider supports Standard version [v1.4.0](https://github.com/kubernetes-sigs/gateway-api/releases/tag/v1.4.0) of the Gateway API specification.
It fully supports all `HTTPRoute` core and some extended features, like `BackendTLSPolicy`, and `GRPCRoute` resources from the [Standard channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels), as well as `TCPRoute`, and `TLSRoute` resources from the [Experimental channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels).
It fully supports all HTTP core and some extended features, as well as the `TCPRoute` and `TLSRoute` resources from the [Experimental channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels).
For more details, check out the conformance [report](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports/v1.4.0/traefik-traefik).

View File

@@ -8,12 +8,11 @@ description: "The Kubernetes Gateway API can be used as a provider for routing a
When using the Kubernetes Gateway API provider, Traefik leverages the Gateway API Custom Resource Definitions (CRDs) to obtain its routing configuration.
For detailed information on the Gateway API concepts and resources, refer to the official [documentation](https://gateway-api.sigs.k8s.io/).
The Kubernetes Gateway API provider supports version [v1.4.0](https://github.com/kubernetes-sigs/gateway-api/releases/tag/v1.4.0) of the specification.
The Kubernetes Gateway API provider supports version [v1.2.1](https://github.com/kubernetes-sigs/gateway-api/releases/tag/v1.2.1) of the specification.
It fully supports all `HTTPRoute` core and some extended features, like `BackendTLSPolicy`, and `GRPCRoute` resources from the [Standard channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels), as well as `TCPRoute`, and `TLSRoute` resources from the [Experimental channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels).
For more details, check out the conformance [report](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports/v1.4.0/traefik-traefik).
It fully supports all `HTTPRoute` core and some extended features, like `GRPCRoute`, as well as the `TCPRoute` and `TLSRoute` resources from the [Experimental channel](https://gateway-api.sigs.k8s.io/concepts/versioning/?h=#release-channels).
For more details, check out the conformance [report](https://github.com/kubernetes-sigs/gateway-api/tree/main/conformance/reports/v1.2.1/traefik-traefik).
## Deploying a Gateway

View File

@@ -294,7 +294,7 @@ Limitations or behavioral differences are indicated where relevant.
| <a id="opt-nginx-ingress-kubernetes-ioproxy-ssl-secret" href="#opt-nginx-ingress-kubernetes-ioproxy-ssl-secret" title="#opt-nginx-ingress-kubernetes-ioproxy-ssl-secret">`nginx.ingress.kubernetes.io/proxy-ssl-secret`</a> | |
| <a id="opt-nginx-ingress-kubernetes-ioservice-upstream" href="#opt-nginx-ingress-kubernetes-ioservice-upstream" title="#opt-nginx-ingress-kubernetes-ioservice-upstream">`nginx.ingress.kubernetes.io/service-upstream`</a> | |
**Unsupported NGINX Annotations**
### Unsupported NGINX Annotations
!!! question "Want to Add Support for More Annotations?"

View File

@@ -568,9 +568,8 @@ func (i *ingress) deprecationNotice(logger zerolog.Logger) {
}
type experimental struct {
HTTP3 *bool `json:"http3,omitempty" toml:"http3,omitempty" yaml:"http3,omitempty"`
KubernetesGateway *bool `json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty"`
KubernetesIngressNGINX *bool `json:"kubernetesIngressNGINX,omitempty" toml:"kubernetesIngressNGINX,omitempty" yaml:"kubernetesIngressNGINX,omitempty"`
HTTP3 *bool `json:"http3,omitempty" toml:"http3,omitempty" yaml:"http3,omitempty"`
KubernetesGateway *bool `json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty"`
}
func (e *experimental) deprecationNotice(logger zerolog.Logger) bool {
@@ -592,12 +591,6 @@ func (e *experimental) deprecationNotice(logger zerolog.Logger) bool {
" For more information please read the migration guide: https://doc.traefik.io/traefik/v3.6/migration/v3/#gateway-api-kubernetesgateway-provider")
}
if e.KubernetesIngressNGINX != nil {
logger.Error().Msg("KubernetesIngressNGINX provider is not an experimental feature starting with v3.6.2." +
" Please remove its usage from the install configuration." +
" For more information please read the migration guide: https://doc.traefik.io/traefik/v3.6/migration/v3/#ingress-nginx-provider")
}
return false
}

View File

@@ -4,15 +4,14 @@ import "github.com/traefik/traefik/v3/pkg/plugins"
// Experimental experimental Traefik features.
type Experimental struct {
Plugins map[string]plugins.Descriptor `description:"Plugins configuration." json:"plugins,omitempty" toml:"plugins,omitempty" yaml:"plugins,omitempty" export:"true"`
LocalPlugins map[string]plugins.LocalDescriptor `description:"Local plugins configuration." json:"localPlugins,omitempty" toml:"localPlugins,omitempty" yaml:"localPlugins,omitempty" export:"true"`
AbortOnPluginFailure bool `description:"Defines whether all plugins must be loaded successfully for Traefik to start." json:"abortOnPluginFailure,omitempty" toml:"abortOnPluginFailure,omitempty" yaml:"abortOnPluginFailure,omitempty" export:"true"`
FastProxy *FastProxyConfig `description:"Enables the FastProxy implementation." json:"fastProxy,omitempty" toml:"fastProxy,omitempty" yaml:"fastProxy,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
OTLPLogs bool `description:"Enables the OpenTelemetry logs integration." json:"otlplogs,omitempty" toml:"otlplogs,omitempty" yaml:"otlplogs,omitempty" export:"true"`
Knative bool `description:"Allow the Knative provider usage." json:"knative,omitempty" toml:"knative,omitempty" yaml:"knative,omitempty" export:"true"`
Plugins map[string]plugins.Descriptor `description:"Plugins configuration." json:"plugins,omitempty" toml:"plugins,omitempty" yaml:"plugins,omitempty" export:"true"`
LocalPlugins map[string]plugins.LocalDescriptor `description:"Local plugins configuration." json:"localPlugins,omitempty" toml:"localPlugins,omitempty" yaml:"localPlugins,omitempty" export:"true"`
AbortOnPluginFailure bool `description:"Defines whether all plugins must be loaded successfully for Traefik to start." json:"abortOnPluginFailure,omitempty" toml:"abortOnPluginFailure,omitempty" yaml:"abortOnPluginFailure,omitempty" export:"true"`
FastProxy *FastProxyConfig `description:"Enables the FastProxy implementation." json:"fastProxy,omitempty" toml:"fastProxy,omitempty" yaml:"fastProxy,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"`
OTLPLogs bool `description:"Enables the OpenTelemetry logs integration." json:"otlplogs,omitempty" toml:"otlplogs,omitempty" yaml:"otlplogs,omitempty" export:"true"`
Knative bool `description:"Allow the Knative provider usage." json:"knative,omitempty" toml:"knative,omitempty" yaml:"knative,omitempty" export:"true"`
KubernetesIngressNGINX bool `description:"Allow the Kubernetes Ingress NGINX provider usage." json:"kubernetesIngressNGINX,omitempty" toml:"kubernetesIngressNGINX,omitempty" yaml:"kubernetesIngressNGINX,omitempty" export:"true"`
// Deprecated: KubernetesIngressNGINX provider is not an experimental feature starting with v3.6.2. Please remove its usage from the static configuration.
KubernetesIngressNGINX bool `description:"Allow the Kubernetes Ingress NGINX provider usage." json:"kubernetesIngressNGINX,omitempty" toml:"kubernetesIngressNGINX,omitempty" yaml:"kubernetesIngressNGINX,omitempty" export:"true"`
// Deprecated: KubernetesGateway provider is not an experimental feature starting with v3.1. Please remove its usage from the static configuration.
KubernetesGateway bool `description:"(Deprecated) Allow the Kubernetes gateway api provider usage." json:"kubernetesGateway,omitempty" toml:"kubernetesGateway,omitempty" yaml:"kubernetesGateway,omitempty" export:"true"`
}

View File

@@ -424,6 +424,10 @@ func (c *Configuration) ValidateConfiguration() error {
}
if c.Providers != nil && c.Providers.KubernetesIngressNGINX != nil {
if c.Experimental == nil || !c.Experimental.KubernetesIngressNGINX {
return errors.New("the experimental KubernetesIngressNGINX feature must be enabled to use the KubernetesIngressNGINX provider")
}
if c.Providers.KubernetesIngressNGINX.WatchNamespace != "" && c.Providers.KubernetesIngressNGINX.WatchNamespaceSelector != "" {
return errors.New("watchNamespace and watchNamespaceSelector options are mutually exclusive")
}

View File

@@ -21,6 +21,9 @@ import (
"github.com/traefik/traefik/v3/pkg/safe"
)
// DockerAPIVersion is a constant holding the version of the Provider API traefik will use.
const DockerAPIVersion = "1.24"
const dockerName = "docker"
var _ provider.Provider = (*Provider)(nil)
@@ -51,6 +54,7 @@ func (p *Provider) Init() error {
}
func (p *Provider) createClient(ctx context.Context) (*client.Client, error) {
p.ClientConfig.apiVersion = DockerAPIVersion
return createClient(ctx, p.ClientConfig)
}

View File

@@ -22,6 +22,9 @@ import (
"github.com/traefik/traefik/v3/pkg/safe"
)
// SwarmAPIVersion is a constant holding the version of the Provider API traefik will use.
const SwarmAPIVersion = "1.24"
const swarmName = "swarm"
var _ provider.Provider = (*SwarmProvider)(nil)
@@ -55,6 +58,7 @@ func (p *SwarmProvider) Init() error {
}
func (p *SwarmProvider) createClient(ctx context.Context) (*client.Client, error) {
p.ClientConfig.apiVersion = SwarmAPIVersion
return createClient(ctx, p.ClientConfig)
}

View File

@@ -100,6 +100,8 @@ func parseContainer(container containertypes.InspectResponse) dockerData {
}
type ClientConfig struct {
apiVersion string
Username string `description:"Username for Basic HTTP authentication." json:"username,omitempty" toml:"username,omitempty" yaml:"username,omitempty"`
Password string `description:"Password for Basic HTTP authentication." json:"password,omitempty" toml:"password,omitempty" yaml:"password,omitempty"`
Endpoint string `description:"Docker server endpoint. Can be a TCP or a Unix socket endpoint." json:"endpoint,omitempty" toml:"endpoint,omitempty" yaml:"endpoint,omitempty"`
@@ -121,9 +123,8 @@ func createClient(ctx context.Context, cfg ClientConfig) (*client.Client, error)
}
opts = append(opts,
client.FromEnv,
client.WithAPIVersionNegotiation(),
client.WithHTTPHeaders(httpHeaders))
client.WithHTTPHeaders(httpHeaders),
client.WithVersion(cfg.apiVersion))
return client.NewClientWithOpts(opts...)
}

View File

@@ -175,11 +175,9 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration {
if cfg.HTTP != nil && len(cfg.HTTP.Models) > 0 {
rts := make(map[string]*dynamic.Router)
modelRouterNames := make(map[string][]string)
for name, rt := range cfg.HTTP.Routers {
// Only root routers can have models applied.
if rt.ParentRefs != nil {
rts[name] = rt
continue
}
@@ -235,9 +233,7 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration {
rtName := name
if len(eps) > 1 {
rtName = epName + "-" + name
modelRouterNames[name] = append(modelRouterNames[name], rtName)
}
rts[rtName] = cp
} else {
router.EntryPoints = append(router.EntryPoints, epName)
@@ -247,26 +243,6 @@ func applyModel(cfg dynamic.Configuration) dynamic.Configuration {
}
}
for _, rt := range rts {
if rt.ParentRefs == nil {
continue
}
var parentRefs []string
for _, ref := range rt.ParentRefs {
// Only add the initial parent ref if it still exists.
if _, ok := rts[ref]; ok {
parentRefs = append(parentRefs, ref)
}
if names, ok := modelRouterNames[ref]; ok {
parentRefs = append(parentRefs, names...)
}
}
rt.ParentRefs = parentRefs
}
cfg.HTTP.Routers = rts
}

View File

@@ -810,414 +810,6 @@ func Test_applyModel(t *testing.T) {
},
},
},
{
desc: "child router with parentRefs, parent not split",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
},
"child": {
ParentRefs: []string{"parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: make(map[string]*dynamic.Model),
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child": {
ParentRefs: []string{"parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: make(map[string]*dynamic.Model),
},
},
},
{
desc: "child router with parentRefs, parent split by model",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"websecure", "web"},
},
"child": {
ParentRefs: []string{"parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
Middlewares: []string{"test"},
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"websecure-parent": {
EntryPoints: []string{"websecure"},
Middlewares: []string{"test"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child": {
ParentRefs: []string{"parent", "websecure-parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
Middlewares: []string{"test"},
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
},
{
desc: "multiple child routers with parentRefs, parent split by model",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"websecure", "web"},
},
"child1": {
ParentRefs: []string{"parent"},
},
"child2": {
ParentRefs: []string{"parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
Middlewares: []string{"auth"},
},
},
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"websecure-parent": {
EntryPoints: []string{"websecure"},
Middlewares: []string{"auth"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child1": {
ParentRefs: []string{"parent", "websecure-parent"},
},
"child2": {
ParentRefs: []string{"parent", "websecure-parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
Middlewares: []string{"auth"},
},
},
},
},
},
{
desc: "child router with parentRefs to non-existing parent",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"child": {
ParentRefs: []string{"nonexistent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: make(map[string]*dynamic.Model),
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"child": {
ParentRefs: []string{"nonexistent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: make(map[string]*dynamic.Model),
},
},
},
{
desc: "child router with multiple parentRefs, some split",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent1": {
EntryPoints: []string{"websecure", "web"},
},
"parent2": {
EntryPoints: []string{"web"},
},
"child": {
ParentRefs: []string{"parent1", "parent2"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent1": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"websecure-parent1": {
EntryPoints: []string{"websecure"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"parent2": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child": {
ParentRefs: []string{"parent1", "websecure-parent1", "parent2"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
},
{
desc: "child router with multiple parentRefs, all split",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent1": {
EntryPoints: []string{"websecure", "web"},
},
"parent2": {
EntryPoints: []string{"web"},
},
"child": {
ParentRefs: []string{"parent1", "parent2"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"web@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"web-parent1": {
EntryPoints: []string{"web"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"websecure-parent1": {
EntryPoints: []string{"websecure"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"parent2": {
EntryPoints: []string{"web"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child": {
ParentRefs: []string{"websecure-parent1", "web-parent1", "parent2"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"web@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
},
},
},
},
{
desc: "child router with parentRefs, parent split into three routers",
input: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"websecure", "web", "admin"},
},
"child": {
ParentRefs: []string{"parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
"admin@internal": {
Middlewares: []string{"admin-auth"},
},
},
},
},
expected: dynamic.Configuration{
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"parent": {
EntryPoints: []string{"web"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"websecure-parent": {
EntryPoints: []string{"websecure"},
TLS: &dynamic.RouterTLSConfig{},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"admin-parent": {
EntryPoints: []string{"admin"},
Middlewares: []string{"admin-auth"},
Observability: &dynamic.RouterObservabilityConfig{
AccessLogs: pointer(true),
Metrics: pointer(true),
Tracing: pointer(true),
TraceVerbosity: otypes.MinimalVerbosity,
},
},
"child": {
ParentRefs: []string{"parent", "websecure-parent", "admin-parent"},
},
},
Middlewares: make(map[string]*dynamic.Middleware),
Services: make(map[string]*dynamic.Service),
Models: map[string]*dynamic.Model{
"websecure@internal": {
TLS: &dynamic.RouterTLSConfig{},
},
"admin@internal": {
Middlewares: []string{"admin-auth"},
},
},
},
},
},
}
for _, test := range testCases {

View File

@@ -32,6 +32,9 @@ type Listener struct {
// timeout defines how long to wait on an idle session,
// before releasing its related resources.
timeout time.Duration
// readBufferPool is a pool of byte slices for UDP packet reading.
readBufferPool sync.Pool
}
// ListenPacketConn creates a new listener from PacketConn.
@@ -51,6 +54,11 @@ func ListenPacketConn(packetConn net.PacketConn, timeout time.Duration) (*Listen
conns: make(map[string]*Conn),
accepting: true,
timeout: timeout,
readBufferPool: sync.Pool{
New: func() interface{} {
return make([]byte, maxDatagramSize)
},
},
}
go l.readLoop()
@@ -152,21 +160,26 @@ func (l *Listener) readLoop() {
for {
// Allocating a new buffer for every read avoids
// overwriting data in c.msgs in case the next packet is received
// before c.msgs is emptied via Read()
buf := make([]byte, maxDatagramSize)
// before c.msgs is emptied via Read().
// Reuses buffers via the readBufferPool sync.Pool.
buf := l.readBufferPool.Get().([]byte)
n, raddr, err := l.pConn.ReadFrom(buf)
if err != nil {
l.readBufferPool.Put(buf)
return
}
conn, err := l.getConn(raddr)
if err != nil {
l.readBufferPool.Put(buf)
continue
}
select {
// Receiver must call releaseReadBuffer() when done reading the data.
case conn.receiveCh <- buf[:n]:
case <-conn.doneCh:
l.readBufferPool.Put(buf)
continue
}
}
@@ -211,15 +224,15 @@ type Conn struct {
listener *Listener
rAddr net.Addr
receiveCh chan []byte // to receive the data from the listener's readLoop
readCh chan []byte // to receive the buffer into which we should Read
sizeCh chan int // to synchronize with the end of a Read
msgs [][]byte // to store data from listener, to be consumed by Reads
receiveCh chan []byte // to receive the data from the listener's readLoop.
readCh chan []byte // to receive the buffer into which we should Read.
sizeCh chan int // to synchronize with the end of a Read.
msgs [][]byte // to store data from listener, to be consumed by Reads.
muActivity sync.RWMutex
lastActivity time.Time // the last time the session saw either read or write activity
lastActivity time.Time // the last time the session saw either read or write activity.
timeout time.Duration // for timeouts
timeout time.Duration // for timeouts.
doneOnce sync.Once
doneCh chan struct{}
}
@@ -254,6 +267,8 @@ func (c *Conn) readLoop() {
msg := c.msgs[0]
c.msgs = c.msgs[1:]
n := copy(cBuf, msg)
// Return buffer to sync.Pool once done reading from it.
c.listener.readBufferPool.Put(msg)
c.sizeCh <- n
case msg := <-c.receiveCh:
c.msgs = append(c.msgs, msg)
@@ -299,6 +314,11 @@ func (c *Conn) Write(p []byte) (n int, err error) {
func (c *Conn) close() {
c.doneOnce.Do(func() {
// Release any buffered data before closing.
for _, msg := range c.msgs {
c.listener.readBufferPool.Put(msg)
}
c.msgs = nil
close(c.doneCh)
})
}

View File

@@ -4,11 +4,11 @@ RepositoryName = "traefik"
OutputType = "file"
FileName = "traefik_changelog.md"
# example new bugfix v3.6.2
CurrentRef = "v3.6"
PreviousRef = "v3.6.1"
BaseBranch = "v3.6"
FutureCurrentRefName = "v3.6.2"
# example new bugfix v3.5.6
CurrentRef = "v3.5"
PreviousRef = "v3.5.5"
BaseBranch = "v3.5"
FutureCurrentRefName = "v3.5.6"
ThresholdPreviousRef = 10
ThresholdCurrentRef = 10

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,7 @@
import { AriaTr, VariantProps, styled } from '@traefiklabs/faency'
import { ComponentProps, forwardRef, ReactNode } from 'react'
import { useHref } from 'react-router-dom'
import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to'
const UnstyledLink = styled('a', {
color: 'inherit',
@@ -18,7 +19,7 @@ type ClickableRowProps = ComponentProps<typeof AriaTr> &
}
export default forwardRef<HTMLTableRowElement | null, ClickableRowProps>(({ children, css, to, ...props }, ref) => {
const href = useHref(to)
const href = useHrefWithReturnTo(to)
return (
<AriaTr asChild interactive ref={ref} css={css} {...props}>

View File

@@ -7,6 +7,7 @@ import { StatusWrapper } from './ResourceStatus'
import { colorByStatus } from './Status'
import Tooltip from 'components/Tooltip'
import { useGetUrlWithReturnTo } from 'hooks/use-href-with-return-to'
const CustomHeading = styled(H2, {
display: 'flex',
@@ -125,9 +126,25 @@ const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
const CardItem = ({ card }) => {
const navigate = useNavigate()
const href = useGetUrlWithReturnTo(card.link)
return (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(href)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
return (
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
<SectionHeader icon={icon} title={title} />
@@ -135,20 +152,7 @@ export const CardListSection = ({ icon, title, cards, isLast, bigDescription }:
<CardListColumn>
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
{!cards && <CardSkeleton bigDescription={bigDescription} />}
{cards
?.filter((c) => !!c.description)
.map((card) => (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(card.link)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
))}
{cards?.filter((c) => !!c.description).map((card, idx) => <CardItem key={`card-${idx}`} card={card} />)}
<Box css={{ height: '16px' }}>&nbsp;</Box>
</Flex>
</CardListColumn>

View File

@@ -0,0 +1,238 @@
import { renderHook } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { useGetUrlWithReturnTo, useHrefWithReturnTo, useRouterReturnTo } from './use-href-with-return-to'
describe('useGetUrlWithReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should append current path as returnTo query param', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath')
})
it('should append current path with search params as returnTo', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target'), {
wrapper: createWrapper('/current/path?foo=bar'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%2Fpath%3Ffoo%3Dbar')
})
it('should use initialReturnTo when provided', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target', '/custom/return'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
})
it('should return the href as-is when href is empty string', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo(''), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('')
})
it('should handle href with existing query params', () => {
const { result } = renderHook(() => useGetUrlWithReturnTo('/target?existing=param'), {
wrapper: createWrapper('/current/path'),
})
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent%2Fpath')
})
})
describe('useHrefWithReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should return resolved href with returnTo param containing current path', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent')
})
it('should include current search params in returnTo', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/current?foo=bar&baz=qux'),
})
expect(result.current).toBe('/target?returnTo=%2Fcurrent%3Ffoo%3Dbar%26baz%3Dqux')
})
it('should use custom returnTo when provided instead of current path', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target', '/custom/return'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?returnTo=%2Fcustom%2Freturn')
})
it('should handle absolute paths correctly', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/http/routers'), {
wrapper: createWrapper('/tcp/services'),
})
expect(result.current).toBe('/http/routers?returnTo=%2Ftcp%2Fservices')
})
it('should preserve existing query params in target href', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target?existing=param'), {
wrapper: createWrapper('/current'),
})
expect(result.current).toBe('/target?existing=param&returnTo=%2Fcurrent')
})
it('should return root path when href is empty', () => {
const { result } = renderHook(() => useHrefWithReturnTo(''), {
wrapper: createWrapper('/current'),
})
// useHref converts empty string to root path
expect(result.current).toBe('/')
})
it('should handle complex nested paths in returnTo', () => {
const { result } = renderHook(() => useHrefWithReturnTo('/target'), {
wrapper: createWrapper('/http/routers/my-router-123'),
})
expect(result.current).toBe('/target?returnTo=%2Fhttp%2Frouters%2Fmy-router-123')
})
})
describe('useRouterReturnTo', () => {
const createWrapper = (initialPath = '/') => {
return ({ children }) => <MemoryRouter initialEntries={[initialPath]}>{children}</MemoryRouter>
}
it('should return null when no returnTo query param exists', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current'),
})
expect(result.current.returnTo).toBeNull()
expect(result.current.returnToLabel).toBeNull()
})
it('should extract returnTo from query params', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers'),
})
expect(result.current.returnTo).toBe('/http/routers')
})
it('should generate correct label for HTTP routers (plural)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers'),
})
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should generate correct label for HTTP router (singular)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers/router-1'),
})
expect(result.current.returnToLabel).toBe('HTTP router')
})
it('should generate fallback label for unknown routes (plural)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/custom/resources'),
})
expect(result.current.returnToLabel).toBe('Custom resources')
})
it('should handle malformed returnTo paths gracefully', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/'),
})
expect(result.current.returnTo).toBe('/')
expect(result.current.returnToLabel).toBe('Back')
})
it('should handle returnTo with query params', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test'),
})
expect(result.current.returnTo).toContain('/http/routers')
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should strip query params from path when generating label', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test&status=active'),
})
expect(result.current.returnToLabel).toBe('HTTP routers')
expect(result.current.returnToLabel).not.toContain('filter')
expect(result.current.returnToLabel).not.toContain('status')
})
it('should strip query params from subpath when generating label', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/tcp/services?page=2'),
})
expect(result.current.returnToLabel).toBe('TCP services')
})
it('should handle query params with multiple question marks gracefully', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/routers?filter=test?extra=param'),
})
// Should handle edge case with multiple question marks (invalid URL but should not crash)
expect(result.current.returnToLabel).toBe('HTTP routers')
})
it('should handle path with query params but no subpath', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http?foo=bar'),
})
expect(result.current.returnToLabel).toBe('Http')
})
it('should handle empty query string (path ending with ?)', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/tcp/middlewares?'),
})
expect(result.current.returnToLabel).toBe('TCP middlewares')
})
it('should handle complex query strings with special characters', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/http/services?filter=%40test%23special'),
})
expect(result.current.returnToLabel).toBe('HTTP services')
})
it('should capitalize first letter of label override', () => {
const { result } = renderHook(() => useRouterReturnTo(), {
wrapper: createWrapper('/current?returnTo=/resource/routers/router-1'),
})
// Verify the label starts with uppercase
expect(result.current.returnToLabel?.charAt(0)).toBe('R')
})
})

View File

@@ -0,0 +1,119 @@
import qs from 'query-string'
import { useMemo } from 'react'
import { useHref, useLocation, useSearchParams } from 'react-router-dom'
import { capitalizeFirstLetter } from '../utils/string'
type UseGetUrlWithReturnTo = (href: string, initialReturnTo?: string) => string
export const useGetUrlWithReturnTo: UseGetUrlWithReturnTo = (href, initialReturnTo) => {
const location = useLocation()
const currentPath = location.pathname + location.search
const url = useMemo(() => {
if (href) {
return qs.stringifyUrl({ url: href, query: { returnTo: initialReturnTo ?? currentPath } })
}
return href
}, [currentPath, href, initialReturnTo])
return url
}
export const useHrefWithReturnTo = (href: string, returnTo?: string): string => {
const urlWithReturnTo = useGetUrlWithReturnTo(href, returnTo)
return useHref(urlWithReturnTo)
}
const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record<string, Record<string, string>> = {
http: {
routers: 'HTTP router',
services: 'HTTP service',
middlewares: 'HTTP middleware',
},
tcp: {
routers: 'TCP router',
services: 'TCP service',
middlewares: 'TCP middleware',
},
udp: {
routers: 'UDP router',
services: 'TCP service',
},
}
const RETURN_TO_LABEL_OVERRIDES_PLURAL: Record<string, Record<string, string>> = {
http: {
routers: 'HTTP routers',
services: 'HTTP services',
middlewares: 'HTTP middlewares',
},
tcp: {
routers: 'TCP routers',
services: 'TCP services',
middlewares: 'TCP middlewares',
},
udp: {
routers: 'UDP routers',
services: 'TCP services',
},
}
type UseRouterReturnTo = (initialReturnTo?: string) => {
returnTo: string | null
returnToLabel: string | null
}
const getCleanPath = (path: string) => {
if (!path) return ''
return path.split('?')[0]
}
export const useRouterReturnTo: UseRouterReturnTo = () => {
const [searchParams] = useSearchParams()
const returnTo = useMemo(() => {
const queryReturnTo = searchParams.get('returnTo')
return queryReturnTo || null
}, [searchParams])
const returnToHref = useHref(returnTo || '')
const returnToLabel = useMemo(() => {
if (!returnTo) {
return null
}
const returnToArr = returnTo.split('/')
const [, path, subpath, id] = returnToArr
// Strip query params from path, if any
const cleanPath = getCleanPath(path)
const cleanSubpath = getCleanPath(subpath)
// Malformed returnTo (e.g., just '/' or empty path)
if (!cleanPath) {
return 'Back'
}
const fallbackLabel = `${capitalizeFirstLetter(cleanPath)}${cleanSubpath ? ` ${cleanSubpath}` : ''}`
const labelArray = id ? RETURN_TO_LABEL_OVERRIDES_SINGULAR : RETURN_TO_LABEL_OVERRIDES_PLURAL
const labelOverride =
labelArray[cleanPath]?.[cleanSubpath] ??
(typeof labelArray[cleanPath] === 'string' ? labelArray[cleanPath] : fallbackLabel)
return capitalizeFirstLetter(labelOverride)
}, [returnTo])
return useMemo(
() => ({
returnTo: returnTo ? returnToHref : null,
returnToLabel,
}),
[returnTo, returnToHref, returnToLabel],
)
}

View File

@@ -1,131 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import useHubUpgradeButton from './use-hub-upgrade-button'
import { VersionContext } from 'contexts/version'
import verifySignature from 'utils/workers/scriptVerification'
vi.mock('utils/workers/scriptVerification')
const mockVerifySignature = vi.mocked(verifySignature)
const createWrapper = (showHubButton: boolean) => {
return ({ children }: { children: ReactNode }) => (
<VersionContext.Provider value={{ showHubButton, version: '1.0.0' }}>{children}</VersionContext.Provider>
)
}
describe('useHubUpgradeButton Hook', () => {
let originalCreateObjectURL: typeof URL.createObjectURL
let originalRevokeObjectURL: typeof URL.revokeObjectURL
const mockBlobUrl = 'blob:http://localhost:3000/mock-blob-url'
beforeEach(() => {
originalCreateObjectURL = URL.createObjectURL
originalRevokeObjectURL = URL.revokeObjectURL
URL.createObjectURL = vi.fn(() => mockBlobUrl)
URL.revokeObjectURL = vi.fn()
})
afterEach(() => {
URL.createObjectURL = originalCreateObjectURL
URL.revokeObjectURL = originalRevokeObjectURL
vi.clearAllMocks()
})
it('should not verify script when showHubButton is false', async () => {
renderHook(() => useHubUpgradeButton(), {
wrapper: createWrapper(false),
})
await waitFor(() => {
expect(mockVerifySignature).not.toHaveBeenCalled()
})
})
it('should verify script and create blob URL when showHubButton is true and verification succeeds', async () => {
const mockScriptContent = new ArrayBuffer(8)
mockVerifySignature.mockResolvedValue({
verified: true,
scriptContent: mockScriptContent,
})
const { result } = renderHook(() => useHubUpgradeButton(), {
wrapper: createWrapper(true),
})
await waitFor(() => {
expect(mockVerifySignature).toHaveBeenCalledWith(
'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js',
'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js.sig',
'MCowBQYDK2VwAyEAY0OZFFE5kSuqYK6/UprTL5RmvQ+8dpPTGMCw1MiO/Gs=',
)
})
await waitFor(() => {
expect(result.current.signatureVerified).toBe(true)
})
expect(result.current.scriptBlobUrl).toBe(mockBlobUrl)
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
})
it('should set signatureVerified to false when verification fails', async () => {
mockVerifySignature.mockResolvedValue({
verified: false,
})
const { result } = renderHook(() => useHubUpgradeButton(), {
wrapper: createWrapper(true),
})
await waitFor(() => {
expect(mockVerifySignature).toHaveBeenCalled()
})
await waitFor(() => {
expect(result.current.signatureVerified).toBe(false)
})
expect(result.current.scriptBlobUrl).toBeNull()
expect(URL.createObjectURL).not.toHaveBeenCalled()
})
it('should handle verification errors gracefully', async () => {
mockVerifySignature.mockRejectedValue(new Error('Verification failed'))
const { result } = renderHook(() => useHubUpgradeButton(), {
wrapper: createWrapper(true),
})
await waitFor(() => {
expect(mockVerifySignature).toHaveBeenCalled()
})
await waitFor(() => {
expect(result.current.signatureVerified).toBe(false)
})
expect(result.current.scriptBlobUrl).toBeNull()
})
it('should create blob with correct MIME type', async () => {
const mockScriptContent = new ArrayBuffer(8)
mockVerifySignature.mockResolvedValue({
verified: true,
scriptContent: mockScriptContent,
})
renderHook(() => useHubUpgradeButton(), {
wrapper: createWrapper(true),
})
await waitFor(() => {
expect(URL.createObjectURL).toHaveBeenCalled()
})
const blobCall = vi.mocked(URL.createObjectURL).mock.calls[0][0] as Blob
expect(blobCall).toBeInstanceOf(Blob)
expect(blobCall.type).toBe('application/javascript')
})
})

View File

@@ -1,61 +0,0 @@
import { useContext, useEffect, useState } from 'react'
import { VersionContext } from 'contexts/version'
import verifySignature from 'utils/workers/scriptVerification'
const HUB_BUTTON_URL = 'https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js'
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAY0OZFFE5kSuqYK6/UprTL5RmvQ+8dpPTGMCw1MiO/Gs='
const useHubUpgradeButton = () => {
const [signatureVerified, setSignatureVerified] = useState(false)
const [scriptBlobUrl, setScriptBlobUrl] = useState<string | null>(null)
const [isCustomElementDefined, setIsCustomElementDefined] = useState(false)
const { showHubButton } = useContext(VersionContext)
useEffect(() => {
if (showHubButton) {
if (customElements.get('hub-button-app')) {
setSignatureVerified(true)
setIsCustomElementDefined(true)
return
}
const verifyAndLoadScript = async () => {
try {
const { verified, scriptContent: content } = await verifySignature(
HUB_BUTTON_URL,
`${HUB_BUTTON_URL}.sig`,
PUBLIC_KEY,
)
if (!verified || !content) {
setSignatureVerified(false)
} else {
const blob = new Blob([content], { type: 'application/javascript' })
const blobUrl = URL.createObjectURL(blob)
setScriptBlobUrl(blobUrl)
setSignatureVerified(true)
}
} catch {
setSignatureVerified(false)
}
}
verifyAndLoadScript()
return () => {
setScriptBlobUrl((currentUrl) => {
if (currentUrl) {
URL.revokeObjectURL(currentUrl)
}
return null
})
}
}
}, [showHubButton])
return { signatureVerified, scriptBlobUrl, isCustomElementDefined }
}
export default useHubUpgradeButton

View File

@@ -1,20 +1,9 @@
import { AriaTd, Flex, Text } from '@traefiklabs/faency'
import { Flex, Text } from '@traefiklabs/faency'
import { FiAlertTriangle } from 'react-icons/fi'
type EmptyPlaceholderProps = {
message?: string
}
export const EmptyPlaceholder = ({ message = 'No data available' }: EmptyPlaceholderProps) => (
export const EmptyPlaceholder = ({ message = 'No data available' }: { message?: string }) => (
<Flex align="center" justify="center" css={{ py: '$5', color: '$primary' }}>
<FiAlertTriangle size={16} />
<Text css={{ pl: '$2' }}>{message}</Text>
</Flex>
)
export const EmptyPlaceholderTd = (props: EmptyPlaceholderProps) => {
return (
<AriaTd css={{ pointerEvents: 'none' }} fullColSpan>
<EmptyPlaceholder {...props} />
</AriaTd>
)
}

View File

@@ -1,96 +0,0 @@
import { waitFor } from '@testing-library/react'
import { SideNav, TopNav } from './Navigation'
import useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
import { renderWithProviders } from 'utils/test'
vi.mock('hooks/use-hub-upgrade-button')
const mockUseHubUpgradeButton = vi.mocked(useHubUpgradeButton)
describe('Navigation', () => {
beforeEach(() => {
mockUseHubUpgradeButton.mockReturnValue({
signatureVerified: false,
scriptBlobUrl: null,
isCustomElementDefined: false,
})
})
afterEach(() => {
vi.clearAllMocks()
})
it('should render the side navigation bar', async () => {
const { container } = renderWithProviders(<SideNav isExpanded={false} onSidePanelToggle={() => {}} />)
expect(container.innerHTML).toContain('HTTP')
expect(container.innerHTML).toContain('TCP')
expect(container.innerHTML).toContain('UDP')
expect(container.innerHTML).toContain('Plugins')
})
it('should render the top navigation bar', async () => {
const { container } = renderWithProviders(<TopNav />)
expect(container.innerHTML).toContain('theme-switcher')
expect(container.innerHTML).toContain('help-menu')
})
describe('hub-button-app rendering', () => {
it('should NOT render hub-button-app when signatureVerified is false', async () => {
mockUseHubUpgradeButton.mockReturnValue({
signatureVerified: false,
scriptBlobUrl: null,
isCustomElementDefined: false,
})
const { container } = renderWithProviders(<TopNav />)
const hubButtonApp = container.querySelector('hub-button-app')
expect(hubButtonApp).toBeNull()
})
it('should NOT render hub-button-app when scriptBlobUrl is null', async () => {
mockUseHubUpgradeButton.mockReturnValue({
signatureVerified: true,
scriptBlobUrl: null,
isCustomElementDefined: false,
})
const { container } = renderWithProviders(<TopNav />)
const hubButtonApp = container.querySelector('hub-button-app')
expect(hubButtonApp).toBeNull()
})
it('should render hub-button-app when signatureVerified is true and scriptBlobUrl exists', async () => {
mockUseHubUpgradeButton.mockReturnValue({
signatureVerified: true,
scriptBlobUrl: 'blob:http://localhost:3000/mock-blob-url',
isCustomElementDefined: false,
})
const { container } = renderWithProviders(<TopNav />)
await waitFor(() => {
const hubButtonApp = container.querySelector('hub-button-app')
expect(hubButtonApp).not.toBeNull()
})
})
it('should NOT render hub-button-app when noHubButton prop is true', async () => {
mockUseHubUpgradeButton.mockReturnValue({
signatureVerified: true,
scriptBlobUrl: 'blob:http://localhost:3000/mock-blob-url',
isCustomElementDefined: false,
})
const { container } = renderWithProviders(<TopNav noHubButton={true} />)
const hubButtonApp = container.querySelector('hub-button-app')
expect(hubButtonApp).toBeNull()
})
})
})

View File

@@ -4,10 +4,10 @@ import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import Container from './Container'
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from './Navigation'
import { ToastPool } from 'components/ToastPool'
import { ToastProvider } from 'contexts/toasts'
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from 'layout/navigation'
export const LIGHT_PRIMARY_COLOR = '#217F97'
export const DARK_PRIMARY_COLOR = '#2AA2C1'

View File

@@ -0,0 +1,21 @@
import { SideNav, TopNav } from '.'
import { renderWithProviders } from 'utils/test'
describe('Navigation', () => {
it('should render the side navigation bar', async () => {
const { container } = renderWithProviders(<SideNav isExpanded onSidePanelToggle={() => {}} />)
expect(container.innerHTML).toContain('HTTP')
expect(container.innerHTML).toContain('TCP')
expect(container.innerHTML).toContain('UDP')
expect(container.innerHTML).toContain('Plugins')
})
it('should render the top navigation bar', async () => {
const { container } = renderWithProviders(<TopNav />)
expect(container.innerHTML).toContain('theme-switcher')
expect(container.innerHTML).toContain('help-menu')
})
})

View File

@@ -1,18 +1,9 @@
import {
Badge,
Box,
Button,
CSS,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
elevationVariants,
Flex,
Link,
NavigationLink,
SidePanel,
styled,
@@ -21,30 +12,24 @@ import {
VisuallyHidden,
} from '@traefiklabs/faency'
import { useContext, useEffect, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { BsChevronDoubleRight, BsChevronDoubleLeft } from 'react-icons/bs'
import { FiBookOpen, FiGithub, FiHelpCircle } from 'react-icons/fi'
import { matchPath, useHref } from 'react-router'
import { useLocation } from 'react-router-dom'
import { useWindowSize } from 'usehooks-ts'
import Container from './Container'
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from './Page'
import Container from '../Container'
import { LAPTOP_BP } from '.'
import IconButton from 'components/buttons/IconButton'
import Logo from 'components/icons/Logo'
import { PluginsIcon } from 'components/icons/PluginsIcon'
import ThemeSwitcher from 'components/ThemeSwitcher'
import TooltipText from 'components/TooltipText'
import { VersionContext } from 'contexts/version'
import useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
import useTotals from 'hooks/use-overview-totals'
import { useIsDarkMode } from 'hooks/use-theme'
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
import { Route, ROUTES } from 'routes'
export const LAPTOP_BP = 1025
const NavigationDrawer = styled(Flex, {
width: '100%',
maxWidth: '100%',
@@ -63,11 +48,13 @@ export const BasicNavigationItem = ({
count,
isSmallScreen,
isExpanded,
onSidePanelToggle,
}: {
route: Route
count?: number
isSmallScreen: boolean
isExpanded: boolean
onSidePanelToggle: (isOpen: boolean) => void
}) => {
const { pathname } = useLocation()
const href = useHref(route.path)
@@ -93,7 +80,13 @@ export const BasicNavigationItem = ({
}
return (
<NavigationLink active={isActiveRoute} startAdornment={route?.icon} css={{ whiteSpace: 'nowrap' }} href={href}>
<NavigationLink
onClick={isSmallScreen ? () => onSidePanelToggle(false) : undefined}
active={isActiveRoute}
startAdornment={route?.icon}
css={{ whiteSpace: 'nowrap' }}
href={href}
>
{route.label}
{!!count && (
<Badge variant={isActiveRoute ? 'green' : undefined} css={{ ml: '$2' }}>
@@ -115,7 +108,7 @@ export const SideBarPanel = ({
return (
<SidePanel
open={isOpen && windowSize.width < LAPTOP_BP}
open={isOpen && windowSize.width <= LAPTOP_BP}
onOpenChange={onOpenChange}
side="left"
css={{ width: 264, p: 0 }}
@@ -147,8 +140,10 @@ export const SideNav = ({
const [isSmallScreen, setIsSmallScreen] = useState(false)
useEffect(() => {
setIsSmallScreen(isResponsive && windowSize.width < LAPTOP_BP)
}, [isExpanded, isResponsive, windowSize.width])
setIsSmallScreen(windowSize.width <= LAPTOP_BP)
}, [isExpanded, windowSize.width])
const isSmallAndResponsive = useMemo(() => isSmallScreen && isResponsive, [isResponsive, isSmallScreen])
const totalValueByPath = useMemo<{ [key: string]: number }>(
() => ({
@@ -166,7 +161,7 @@ export const SideNav = ({
return (
<NavigationDrawer
data-collapsed={isExpanded && isResponsive && isSmallScreen}
data-collapsed={isExpanded && isSmallAndResponsive}
css={{
width: 264,
height: '100vh',
@@ -226,12 +221,11 @@ export const SideNav = ({
? { mt: '$4', px: 0, justifyContent: 'center' }
: undefined,
}}
href="https://github.com/traefik/traefik/"
target="_blank"
href={useHref('/')}
data-testid="proxy-main-nav"
>
<Logo height={isSmallScreen ? 36 : 56} isSmallScreen={isSmallScreen} />
{!!version && !isSmallScreen && (
<Logo height={isSmallAndResponsive ? 36 : 56} isSmallScreen={isSmallAndResponsive} />
{!!version && !isSmallAndResponsive && (
<TooltipText text={version} css={{ maxWidth: 50, fontWeight: '$semiBold' }} isTruncated />
)}
</Flex>
@@ -270,6 +264,7 @@ export const SideNav = ({
count={totalValueByPath[item.path]}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
))}
</Flex>
@@ -288,94 +283,13 @@ export const SideNav = ({
</NavigationLink>
</Flex>
<ApimDemoNavMenu isResponsive={isResponsive} isSmallScreen={isSmallScreen} isExpanded={isExpanded} />
<ApimDemoNavMenu
isResponsive={isResponsive}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
</Container>
</NavigationDrawer>
)
}
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
const { version } = useContext(VersionContext)
const isDarkMode = useIsDarkMode()
const parsedVersion = useMemo(() => {
if (!version) {
return 'master'
}
if (version === 'dev') {
return 'master'
}
const matches = version.match(/^(v?\d+\.\d+)/)
return matches ? 'v' + matches[1] : 'master'
}, [version])
const { signatureVerified, scriptBlobUrl, isCustomElementDefined } = useHubUpgradeButton()
const displayUpgradeToHubButton = useMemo(
() => !noHubButton && signatureVerified && (!!scriptBlobUrl || isCustomElementDefined),
[isCustomElementDefined, noHubButton, scriptBlobUrl, signatureVerified],
)
return (
<>
{displayUpgradeToHubButton && (
<Helmet>
<meta
httpEquiv="Content-Security-Policy"
content="script-src 'self' blob: 'unsafe-inline'; object-src 'none'; base-uri 'self';"
/>
<script src={scriptBlobUrl as string} type="module"></script>
</Helmet>
)}
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
{displayUpgradeToHubButton && (
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
<hub-button-app
key={`dark-mode-${isDarkMode}`}
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
/>
</Box>
)}
<ThemeSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
<FiHelpCircle size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
<DropdownMenuGroup>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiBookOpen size={20} />
<Text>Documentation</Text>
</Flex>
</Link>
</DropdownMenuItem>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href="https://github.com/traefik/traefik/"
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiGithub size={20} />
<Text>Github Repository</Text>
</Flex>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</Flex>
</>
)
}

View File

@@ -0,0 +1,158 @@
import {
Box,
Button,
CSS,
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuTrigger,
Flex,
Link,
Text,
Tooltip,
} from '@traefiklabs/faency'
import { useContext, useEffect, useMemo, useState } from 'react'
import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi'
import { useLocation } from 'react-router-dom'
import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from '../Page'
import ThemeSwitcher from 'components/ThemeSwitcher'
import { VersionContext } from 'contexts/version'
import { useRouterReturnTo } from 'hooks/use-href-with-return-to'
import { useIsDarkMode } from 'hooks/use-theme'
const TopNavBarBackLink = () => {
const { returnTo, returnToLabel } = useRouterReturnTo()
const { pathname } = useLocation()
if (!returnTo || pathname.includes('hub-dashboard')) return <Box />
return (
<Flex css={{ alignItems: 'center', gap: '$2' }}>
<Link href={returnTo}>
<Button as="div" ghost variant="secondary" css={{ boxShadow: 'none', p: 0, pr: '$2' }}>
<FiChevronLeft style={{ paddingRight: '4px' }} />
{returnToLabel || 'Back'}
</Button>
</Link>
</Flex>
)
}
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
const { showHubButton, version } = useContext(VersionContext)
const isDarkMode = useIsDarkMode()
const parsedVersion = useMemo(() => {
if (!version) {
return 'master'
}
if (version === 'dev') {
return 'master'
}
const matches = version.match(/^(v?\d+\.\d+)/)
return matches ? 'v' + matches[1] : 'master'
}, [version])
useEffect(() => {
if (!showHubButton) {
setHasHubButtonComponent(false)
return
}
if (customElements.get('hub-button-app')) {
setHasHubButtonComponent(true)
return
}
const scripts: HTMLScriptElement[] = []
const createScript = (scriptSrc: string): HTMLScriptElement => {
const script = document.createElement('script')
script.src = scriptSrc
script.async = true
script.onload = () => {
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
}
scripts.push(script)
return script
}
// Source: https://github.com/traefik/traefiklabs-hub-button-app
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
return () => {
// Remove the scripts on unmount.
scripts.forEach((script) => {
if (script.parentNode) {
script.parentNode.removeChild(script)
}
})
}
}, [showHubButton])
return (
<Flex as="nav" role="navigation" justify="space-between" align="center" css={{ mb: '$6', ...css }}>
<TopNavBarBackLink />
<Flex gap={2} align="center">
{!noHubButton && hasHubButtonComponent && (
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
<hub-button-app
key={`dark-mode-${isDarkMode}`}
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
/>
</Box>
)}
<Tooltip content="Sponsor" side="bottom">
<Link href="https://github.com/sponsors/traefik" target="_blank">
<Button as="div" ghost css={{ px: '$2', boxShadow: 'none' }}>
<FiHeart size={20} color="#db61a2" />
</Button>
</Link>
</Tooltip>
<ThemeSwitcher />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
<FiHelpCircle size={20} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
<DropdownMenuGroup>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiBookOpen size={20} />
<Text>Documentation</Text>
</Flex>
</Link>
</DropdownMenuItem>
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
<Link
href="https://github.com/traefik/traefik/"
target="_blank"
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
>
<Flex align="center" gap={2}>
<FiGithub size={20} />
<Text>Github Repository</Text>
</Flex>
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
</Flex>
</Flex>
)
}

View File

@@ -0,0 +1,5 @@
// common breakpoint for large screen, cf. https://www.w3schools.com/howto/howto_css_media_query_breakpoints.asp
export const LAPTOP_BP = 1200
export * from './SideNavBar'
export * from './TopNavBar'

View File

@@ -14,7 +14,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import { parseMiddlewareType } from 'libs/parsers'
export const makeRowRender = (): RenderRowType => {
@@ -79,7 +79,9 @@ export const HttpMiddlewaresRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -16,7 +16,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (protocol = 'http'): RenderRowType => {
const HttpRoutersRenderRow = (row) => (
@@ -100,7 +100,9 @@ export const HttpRoutersRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -14,7 +14,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (): RenderRowType => {
const HttpServicesRenderRow = (row) => (
@@ -78,7 +78,9 @@ export const HttpServicesRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -1,13 +1,12 @@
import { waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { PUBLIC_KEY } from './constants'
import HubDashboard, { resetCache } from './HubDashboard'
import verifySignature from './workers/scriptVerification'
import { renderWithProviders } from 'utils/test'
import verifySignature from 'utils/workers/scriptVerification'
vi.mock('utils/workers/scriptVerification', () => ({
vi.mock('./workers/scriptVerification', () => ({
default: vi.fn(),
}))
@@ -35,6 +34,7 @@ describe('HubDashboard demo', () => {
beforeEach(() => {
vi.clearAllMocks()
// Mock URL.createObjectURL
mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.createObjectURL = mockCreateObjectURL
})
@@ -45,6 +45,7 @@ describe('HubDashboard demo', () => {
describe('without cache', () => {
beforeEach(() => {
// Reset cache before each test suites
resetCache()
})
@@ -129,7 +130,6 @@ describe('HubDashboard demo', () => {
expect(mockVerifyScriptSignature).toHaveBeenCalledWith(
'https://assets.traefik.io/hub-ui-demo.js',
'https://assets.traefik.io/hub-ui-demo.js.sig',
PUBLIC_KEY,
)
})
})

View File

@@ -3,13 +3,11 @@ import { useMemo, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import verifySignature from '../../utils/workers/scriptVerification'
import { PUBLIC_KEY } from './constants'
import verifySignature from './workers/scriptVerification'
import { SpinnerLoader } from 'components/SpinnerLoader'
import { useIsDarkMode } from 'hooks/use-theme'
import { TopNav } from 'layout/Navigation'
import { TopNav } from 'layout/navigation'
const SCRIPT_URL = 'https://assets.traefik.io/hub-ui-demo.js'
@@ -44,7 +42,7 @@ const HubDashboard = ({ path }: { path: string }) => {
setVerificationInProgress(true)
try {
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`, PUBLIC_KEY)
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`)
if (!verified || !content) {
setScriptError(true)

View File

@@ -6,16 +6,18 @@ import { HubDemoContext } from './demoNavContext'
import { HubIcon } from './icons'
import Tooltip from 'components/Tooltip'
import { BasicNavigationItem, LAPTOP_BP } from 'layout/Navigation'
import { BasicNavigationItem, LAPTOP_BP } from 'layout/navigation'
const ApimDemoNavMenu = ({
isResponsive,
isSmallScreen,
isExpanded,
onSidePanelToggle,
}: {
isResponsive: boolean
isSmallScreen: boolean
isExpanded: boolean
onSidePanelToggle: (isOpen: boolean) => void
}) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const { navigationItems: hubDemoNavItems } = useContext(HubDemoContext)
@@ -38,7 +40,7 @@ const ApimDemoNavMenu = ({
transition: 'transform 0.3s ease-in-out',
}}
/>
{isSmallScreen ? (
{isSmallScreen && isResponsive ? (
<Tooltip label="Hub demo">
<Box css={{ ml: 4, color: '$navButtonText' }}>
<HubIcon width={20} />
@@ -74,6 +76,7 @@ const ApimDemoNavMenu = ({
route={route}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
onSidePanelToggle={onSidePanelToggle}
/>
))}
</Box>

View File

@@ -1 +0,0 @@
export const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='

View File

@@ -3,10 +3,9 @@ import { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useHubDemo } from './use-hub-demo'
import verifySignature from './workers/scriptVerification'
import verifySignature from 'utils/workers/scriptVerification'
vi.mock('utils/workers/scriptVerification', () => ({
vi.mock('./workers/scriptVerification', () => ({
default: vi.fn(),
}))

View File

@@ -1,11 +1,9 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { RouteObject } from 'react-router-dom'
import { PUBLIC_KEY } from './constants'
import HubDashboard from 'pages/hub-demo/HubDashboard'
import { ApiIcon, DashboardIcon, GatewayIcon, PortalIcon } from 'pages/hub-demo/icons'
import verifySignature from 'utils/workers/scriptVerification'
import verifySignature from 'pages/hub-demo/workers/scriptVerification'
const ROUTES_MANIFEST_URL = 'https://traefik.github.io/hub-ui-demo-app/config/routes.json'
@@ -22,11 +20,7 @@ const useHubDemoRoutesManifest = (): HubDemo.Manifest | null => {
useEffect(() => {
const fetchManifest = async () => {
try {
const { verified, scriptContent } = await verifySignature(
ROUTES_MANIFEST_URL,
`${ROUTES_MANIFEST_URL}.sig`,
PUBLIC_KEY,
)
const { verified, scriptContent } = await verifySignature(ROUTES_MANIFEST_URL, `${ROUTES_MANIFEST_URL}.sig`)
if (!verified || !scriptContent) {
setManifest(null)

View File

@@ -2,8 +2,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import verifySignature from './scriptVerification'
const SCRIPT_PATH = 'https://example.com/script.js'
const MOCK_PUBLIC_KEY = 'MCowBQYDK2VwAyEAWH71OHphISjNK3mizCR/BawiDxc6IXT1vFHpBcxSIA0='
class MockWorker {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((error: ErrorEvent) => void) | null = null
@@ -48,14 +46,17 @@ describe('verifySignature', () => {
})
it('should return true when verification succeeds', async () => {
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
scriptUrl: SCRIPT_PATH,
signatureUrl: `${SCRIPT_PATH}.sig`,
scriptUrl: scriptPath,
signatureUrl: signaturePath,
requestId: expect.any(String),
}),
)
@@ -75,9 +76,12 @@ describe('verifySignature', () => {
})
it('should return false when verification fails', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -97,12 +101,16 @@ describe('verifySignature', () => {
})
it('should return false when worker throws an error', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(SCRIPT_PATH, `${SCRIPT_PATH}.sig`, MOCK_PUBLIC_KEY)
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
// Simulate worker onerror event
const error = new Error('Worker crashed')
mockWorkerInstance.simulateError(error)

View File

@@ -3,10 +3,12 @@ export interface VerificationResult {
scriptContent?: ArrayBuffer
}
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
async function verifySignature(
contentPath: string,
signaturePath: string,
publicKey: string,
publicKey: string = PUBLIC_KEY,
): Promise<VerificationResult> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).substring(2)

View File

@@ -14,7 +14,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import { parseMiddlewareType } from 'libs/parsers'
export const makeRowRender = (): RenderRowType => {
@@ -79,7 +79,9 @@ export const TcpMiddlewaresRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -16,7 +16,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (): RenderRowType => {
const TcpRoutersRenderRow = (row) => (
@@ -96,7 +96,9 @@ export const TcpRoutersRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -14,7 +14,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (): RenderRowType => {
const TcpServicesRenderRow = (row) => (
@@ -78,7 +78,9 @@ export const TcpServicesRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -15,7 +15,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (): RenderRowType => {
const UdpRoutersRenderRow = (row) => (
@@ -81,7 +81,9 @@ export const UdpRoutersRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -14,7 +14,7 @@ import SortableTh from 'components/tables/SortableTh'
import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
export const makeRowRender = (): RenderRowType => {
const UdpServicesRenderRow = (row) => (
@@ -78,7 +78,9 @@ export const UdpServicesRender = ({
{(isEmpty || !!error) && (
<AriaTfoot>
<AriaTr>
<EmptyPlaceholderTd message={error ? 'Failed to fetch data' : 'No data available'} />
<AriaTd fullColSpan>
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
</AriaTd>
</AriaTr>
</AriaTfoot>
)}

View File

@@ -2871,24 +2871,24 @@ __metadata:
languageName: node
linkType: hard
"@microsoft/api-extractor-model@npm:7.32.0":
version: 7.32.0
resolution: "@microsoft/api-extractor-model@npm:7.32.0"
"@microsoft/api-extractor-model@npm:7.31.3":
version: 7.31.3
resolution: "@microsoft/api-extractor-model@npm:7.31.3"
dependencies:
"@microsoft/tsdoc": "npm:~0.16.0"
"@microsoft/tsdoc-config": "npm:~0.18.0"
"@microsoft/tsdoc": "npm:~0.15.1"
"@microsoft/tsdoc-config": "npm:~0.17.1"
"@rushstack/node-core-library": "npm:5.18.0"
checksum: 10c0/e6d9c54a457c66dec53765522f411c8ca5d2bb8c51559b9f952fdd7bb88b10efe035a8dbdf8b672644c136d75a65f3446b3b18938fa50c0a766f83111cbda5eb
checksum: 10c0/4e4a798c5d92b72fa664932019563f085153cf33f7745f8ea452901348a0021f19c7c3db55e5555b779a78df52d93ec10960349b5bc1ee53bf555e63c0fe1197
languageName: node
linkType: hard
"@microsoft/api-extractor@npm:^7.50.1":
version: 7.55.0
resolution: "@microsoft/api-extractor@npm:7.55.0"
version: 7.54.0
resolution: "@microsoft/api-extractor@npm:7.54.0"
dependencies:
"@microsoft/api-extractor-model": "npm:7.32.0"
"@microsoft/tsdoc": "npm:~0.16.0"
"@microsoft/tsdoc-config": "npm:~0.18.0"
"@microsoft/api-extractor-model": "npm:7.31.3"
"@microsoft/tsdoc": "npm:~0.15.1"
"@microsoft/tsdoc-config": "npm:~0.17.1"
"@rushstack/node-core-library": "npm:5.18.0"
"@rushstack/rig-package": "npm:0.6.0"
"@rushstack/terminal": "npm:0.19.3"
@@ -2902,26 +2902,26 @@ __metadata:
typescript: "npm:5.8.2"
bin:
api-extractor: bin/api-extractor
checksum: 10c0/3211981b7aaf6ca7a36fe33dc9cab5014dc753c0c75d09ace46bef07db947a433ca9daecc843ea13b29fe7527ea3d357c7bd5051fedf10ae3b3db31d2a5de71f
checksum: 10c0/e4708ac5edc3bb32988b632cc75e6f5e5b3afe7772c7229974db91f731d6b8d3c786c406d5437bfce893b78c6f23c64b084db52952a7d35294e2525171a465ee
languageName: node
linkType: hard
"@microsoft/tsdoc-config@npm:~0.18.0":
version: 0.18.0
resolution: "@microsoft/tsdoc-config@npm:0.18.0"
"@microsoft/tsdoc-config@npm:~0.17.1":
version: 0.17.1
resolution: "@microsoft/tsdoc-config@npm:0.17.1"
dependencies:
"@microsoft/tsdoc": "npm:0.16.0"
"@microsoft/tsdoc": "npm:0.15.1"
ajv: "npm:~8.12.0"
jju: "npm:~1.4.0"
resolve: "npm:~1.22.2"
checksum: 10c0/6e2c3bfde3e5fa4c0360127c86fe016dcf1b09d0091d767c06ce916284d3f6aeea3617a33b855c5bb2615ab0f2840eeebd4c7f4a1f879f951828d213bf306cfd
checksum: 10c0/a686355796f492f27af17e2a17d615221309caf4d9f9047a5a8f17f8625c467c4c81e2a7923ddafd71b892631d5e5013c4b8cc49c5867d3cc1d260fd90c1413d
languageName: node
linkType: hard
"@microsoft/tsdoc@npm:0.16.0, @microsoft/tsdoc@npm:~0.16.0":
version: 0.16.0
resolution: "@microsoft/tsdoc@npm:0.16.0"
checksum: 10c0/8883bb0ed22753af7360e9222687fda4eb448f0a574ea34b4596c11e320148b3ae0d24e00f8923df8ba7bc62a46a6f53b9343243a348640d923dfd55d52cd6bb
"@microsoft/tsdoc@npm:0.15.1, @microsoft/tsdoc@npm:~0.15.1":
version: 0.15.1
resolution: "@microsoft/tsdoc@npm:0.15.1"
checksum: 10c0/09948691fac56c45a0d1920de478d66a30371a325bd81addc92eea5654d95106ce173c440fea1a1bd5bb95b3a544b6d4def7bb0b5a846c05d043575d8369a20c
languageName: node
linkType: hard
@@ -6524,26 +6524,26 @@ __metadata:
languageName: node
linkType: hard
"@vue/compiler-core@npm:3.5.24":
version: 3.5.24
resolution: "@vue/compiler-core@npm:3.5.24"
"@vue/compiler-core@npm:3.5.23":
version: 3.5.23
resolution: "@vue/compiler-core@npm:3.5.23"
dependencies:
"@babel/parser": "npm:^7.28.5"
"@vue/shared": "npm:3.5.24"
"@vue/shared": "npm:3.5.23"
entities: "npm:^4.5.0"
estree-walker: "npm:^2.0.2"
source-map-js: "npm:^1.2.1"
checksum: 10c0/d5b1421c0c0cfdff6b6ae2ef3d59b5901f0fec8ad2fa153f5ae1ec8487b898c92766353c661f68b892580ab0eacbc493632c946af8141045d6e76d67797b8a84
checksum: 10c0/195c57b2eb8c6948bf3b1b3f65c2a5a9bf9e252376bcd22bd9b5e1787c4254abc4bffab5f15902c7820f5e607b26d44578cddeb39605ece37b611703c2d6152b
languageName: node
linkType: hard
"@vue/compiler-dom@npm:^3.5.0":
version: 3.5.24
resolution: "@vue/compiler-dom@npm:3.5.24"
version: 3.5.23
resolution: "@vue/compiler-dom@npm:3.5.23"
dependencies:
"@vue/compiler-core": "npm:3.5.24"
"@vue/shared": "npm:3.5.24"
checksum: 10c0/d49cb715f2e1cb2272ede2e41901282fb3f6fbdf489c8aa737e60c68e21216e07b72942695a80430fee8f11e5933e36fc90615b146b189cac925bf32f2727c95
"@vue/compiler-core": "npm:3.5.23"
"@vue/shared": "npm:3.5.23"
checksum: 10c0/fb925b2d64de40c1b39852f5fd26fdec3238f8381ccc2b30a1bef372ef894fff4e6f0231f8a135a02d6a5c8b8254dc7018bcd136a689579a72a3a0e1ff211a89
languageName: node
linkType: hard
@@ -6578,10 +6578,10 @@ __metadata:
languageName: node
linkType: hard
"@vue/shared@npm:3.5.24, @vue/shared@npm:^3.5.0":
version: 3.5.24
resolution: "@vue/shared@npm:3.5.24"
checksum: 10c0/4fd5665539fa5be3d12280c1921a8db3a707115fef54d22d83ce347ea06e3b1089dfe07292e0c46bbebf23553c7c1ec98010972ebccf10532db82422801288ff
"@vue/shared@npm:3.5.23, @vue/shared@npm:^3.5.0":
version: 3.5.23
resolution: "@vue/shared@npm:3.5.23"
checksum: 10c0/0f051ea60a756520b0b0af3d5058587b47f1942476c7f2cee6f78589c97c246acabdea11c73e2f84f13ecfb36c1160aacecca37694144326ebec8c108103bb89
languageName: node
linkType: hard
@@ -10099,9 +10099,9 @@ __metadata:
linkType: hard
"exsolve@npm:^1.0.7":
version: 1.0.8
resolution: "exsolve@npm:1.0.8"
checksum: 10c0/65e44ae05bd4a4a5d87cfdbbd6b8f24389282cf9f85fa5feb17ca87ad3f354877e6af4cd99e02fc29044174891f82d1d68c77f69234410eb8f163530e6278c67
version: 1.0.7
resolution: "exsolve@npm:1.0.7"
checksum: 10c0/4479369d0bd84bb7e0b4f5d9bc18d26a89b6dbbbccd73f9d383d14892ef78ddbe159e01781055342f83dc00ebe90044036daf17ddf55cc21e2cac6609aa15631
languageName: node
linkType: hard
@@ -18670,8 +18670,8 @@ __metadata:
linkType: hard
"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0, vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0":
version: 7.2.2
resolution: "vite@npm:7.2.2"
version: 7.2.1
resolution: "vite@npm:7.2.1"
dependencies:
esbuild: "npm:^0.25.0"
fdir: "npm:^6.5.0"
@@ -18720,7 +18720,7 @@ __metadata:
optional: true
bin:
vite: bin/vite.js
checksum: 10c0/9c76ee441f8dbec645ddaecc28d1f9cf35670ffa91cff69af7b1d5081545331603f0b1289d437b2fa8dc43cdc77b4d96b5bd9c9aed66310f490cb1a06f9c814c
checksum: 10c0/25fbcfc67b1598fa6152f3ed0a7355144a2ac203859ad3b6a2e466b0930ec1081c19cc6f0d83104897517ecf30c0aac3e4a50c4e5e2980d3659decb1d9e41a28
languageName: node
linkType: hard