mirror of
https://github.com/containous/traefik.git
synced 2025-11-24 08:23:52 +03:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bdcd72042 | ||
|
|
2ad42cd0ec |
@@ -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
|
||||
|
||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
23
webui/public/traefiklabs-hub-button-app/main-v1.js
Normal file
23
webui/public/traefiklabs-hub-button-app/main-v1.js
Normal file
File diff suppressed because one or more lines are too long
1
webui/public/traefiklabs-hub-button-app/main-v1.js.map
Normal file
1
webui/public/traefiklabs-hub-button-app/main-v1.js.map
Normal file
File diff suppressed because one or more lines are too long
@@ -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}>
|
||||
|
||||
@@ -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' }}> </Box>
|
||||
</Flex>
|
||||
</CardListColumn>
|
||||
|
||||
238
webui/src/hooks/use-href-with-return-to.spec.tsx
Normal file
238
webui/src/hooks/use-href-with-return-to.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
119
webui/src/hooks/use-href-with-return-to.ts
Normal file
119
webui/src/hooks/use-href-with-return-to.ts
Normal 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],
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
21
webui/src/layout/navigation/Navigation.spec.tsx
Normal file
21
webui/src/layout/navigation/Navigation.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
158
webui/src/layout/navigation/TopNavBar.tsx
Normal file
158
webui/src/layout/navigation/TopNavBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
webui/src/layout/navigation/index.ts
Normal file
5
webui/src/layout/navigation/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user