mirror of
https://github.com/containous/traefik.git
synced 2025-12-18 20:23:55 +03:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
704f69272c | ||
|
|
4854dee208 | ||
|
|
34b91218f4 | ||
|
|
8bdcd72042 | ||
|
|
2ad42cd0ec |
@@ -292,6 +292,7 @@ The following annotations are organized by category for easier navigation.
|
||||
| <a id="opt-nginx-ingress-kubernetes-ioload-balance" href="#opt-nginx-ingress-kubernetes-ioload-balance" title="#opt-nginx-ingress-kubernetes-ioload-balance">`nginx.ingress.kubernetes.io/load-balance`</a> | Only round_robin supported; ewma and IP hash not supported. |
|
||||
| <a id="opt-nginx-ingress-kubernetes-iobackend-protocol" href="#opt-nginx-ingress-kubernetes-iobackend-protocol" title="#opt-nginx-ingress-kubernetes-iobackend-protocol">`nginx.ingress.kubernetes.io/backend-protocol`</a> | FCGI and AUTO_HTTP not supported. |
|
||||
| <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> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | |
|
||||
|
||||
### CORS
|
||||
|
||||
@@ -420,7 +421,6 @@ The following annotations are organized by category for easier navigation.
|
||||
| <a id="opt-nginx-ingress-kubernetes-iomirror-host" href="#opt-nginx-ingress-kubernetes-iomirror-host" title="#opt-nginx-ingress-kubernetes-iomirror-host">`nginx.ingress.kubernetes.io/mirror-host`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-iox-forwarded-prefix" href="#opt-nginx-ingress-kubernetes-iox-forwarded-prefix" title="#opt-nginx-ingress-kubernetes-iox-forwarded-prefix">`nginx.ingress.kubernetes.io/x-forwarded-prefix`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-ioupstream-hash-by" href="#opt-nginx-ingress-kubernetes-ioupstream-hash-by" title="#opt-nginx-ingress-kubernetes-ioupstream-hash-by">`nginx.ingress.kubernetes.io/upstream-hash-by`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-ioupstream-vhost" href="#opt-nginx-ingress-kubernetes-ioupstream-vhost" title="#opt-nginx-ingress-kubernetes-ioupstream-vhost">`nginx.ingress.kubernetes.io/upstream-vhost`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-iodenylist-source-range" href="#opt-nginx-ingress-kubernetes-iodenylist-source-range" title="#opt-nginx-ingress-kubernetes-iodenylist-source-range">`nginx.ingress.kubernetes.io/denylist-source-range`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-iowhitelist-source-range" href="#opt-nginx-ingress-kubernetes-iowhitelist-source-range" title="#opt-nginx-ingress-kubernetes-iowhitelist-source-range">`nginx.ingress.kubernetes.io/whitelist-source-range`</a> | |
|
||||
| <a id="opt-nginx-ingress-kubernetes-ioproxy-buffering" href="#opt-nginx-ingress-kubernetes-ioproxy-buffering" title="#opt-nginx-ingress-kubernetes-ioproxy-buffering">`nginx.ingress.kubernetes.io/proxy-buffering`</a> | |
|
||||
|
||||
@@ -49,6 +49,8 @@ type ingressConfig struct {
|
||||
CORSAllowMethods *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-methods"`
|
||||
CORSAllowOrigin *[]string `annotation:"nginx.ingress.kubernetes.io/cors-allow-origin"`
|
||||
CORSMaxAge *int `annotation:"nginx.ingress.kubernetes.io/cors-max-age"`
|
||||
|
||||
UpstreamVhost *string `annotation:"nginx.ingress.kubernetes.io/upstream-vhost"`
|
||||
}
|
||||
|
||||
// parseIngressConfig parses the annotations from an Ingress object into an ingressConfig struct.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: ingress-with-upstream-vhost
|
||||
namespace: default
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: "upstream-host-header-value"
|
||||
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: upstream-vhost.localhost
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Exact
|
||||
backend:
|
||||
service:
|
||||
name: whoami
|
||||
port:
|
||||
number: 80
|
||||
@@ -800,6 +800,8 @@ func (p *Provider) applyMiddlewares(namespace, routerKey string, ingressConfig i
|
||||
// TODO: check how to remove this, and create the HTTP router elsewhere.
|
||||
applySSLRedirectConfiguration(routerKey, ingressConfig, hasTLS, rt, conf)
|
||||
|
||||
applyUpstreamVhost(routerKey, ingressConfig, rt, conf)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -934,6 +936,21 @@ func applyCORSConfiguration(routerName string, ingressConfig ingressConfig, rt *
|
||||
rt.Middlewares = append(rt.Middlewares, corsMiddlewareName)
|
||||
}
|
||||
|
||||
func applyUpstreamVhost(routerName string, ingressConfig ingressConfig, rt *dynamic.Router, conf *dynamic.Configuration) {
|
||||
if ingressConfig.UpstreamVhost == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vHostMiddlewareName := routerName + "-vhost"
|
||||
conf.HTTP.Middlewares[vHostMiddlewareName] = &dynamic.Middleware{
|
||||
Headers: &dynamic.Headers{
|
||||
CustomRequestHeaders: map[string]string{"Host": *ingressConfig.UpstreamVhost},
|
||||
},
|
||||
}
|
||||
|
||||
rt.Middlewares = append(rt.Middlewares, vHostMiddlewareName)
|
||||
}
|
||||
|
||||
func applySSLRedirectConfiguration(routerName string, ingressConfig ingressConfig, hasTLS bool, rt *dynamic.Router, conf *dynamic.Configuration) {
|
||||
var forceSSLRedirect bool
|
||||
if ingressConfig.ForceSSLRedirect != nil {
|
||||
|
||||
@@ -504,6 +504,58 @@ func TestLoadIngresses(t *testing.T) {
|
||||
TLS: &dynamic.TLSConfiguration{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Upstream vhost",
|
||||
paths: []string{
|
||||
"services.yml",
|
||||
"ingressclasses.yml",
|
||||
"ingresses/10-ingress-with-upstream-vhost.yml",
|
||||
},
|
||||
expected: &dynamic.Configuration{
|
||||
TCP: &dynamic.TCPConfiguration{
|
||||
Routers: map[string]*dynamic.TCPRouter{},
|
||||
Services: map[string]*dynamic.TCPService{},
|
||||
},
|
||||
HTTP: &dynamic.HTTPConfiguration{
|
||||
Routers: map[string]*dynamic.Router{
|
||||
"default-ingress-with-upstream-vhost-rule-0-path-0": {
|
||||
Rule: "Host(`upstream-vhost.localhost`) && Path(`/`)",
|
||||
RuleSyntax: "default",
|
||||
Middlewares: []string{"default-ingress-with-upstream-vhost-rule-0-path-0-vhost"},
|
||||
Service: "default-whoami-80",
|
||||
},
|
||||
},
|
||||
Middlewares: map[string]*dynamic.Middleware{
|
||||
"default-ingress-with-upstream-vhost-rule-0-path-0-vhost": {
|
||||
Headers: &dynamic.Headers{
|
||||
CustomRequestHeaders: map[string]string{"Host": "upstream-host-header-value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Services: map[string]*dynamic.Service{
|
||||
"default-whoami-80": {
|
||||
LoadBalancer: &dynamic.ServersLoadBalancer{
|
||||
Servers: []dynamic.Server{
|
||||
{
|
||||
URL: "http://10.10.0.1:80",
|
||||
},
|
||||
{
|
||||
URL: "http://10.10.0.2:80",
|
||||
},
|
||||
},
|
||||
Strategy: "wrr",
|
||||
PassHostHeader: ptr.To(true),
|
||||
ResponseForwarding: &dynamic.ResponseForwarding{
|
||||
FlushInterval: dynamic.DefaultFlushInterval,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ServersTransports: map[string]*dynamic.ServersTransport{},
|
||||
},
|
||||
TLS: &dynamic.TLSConfiguration{},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "Default Backend",
|
||||
defaultBackendServiceName: "whoami",
|
||||
|
||||
@@ -101,5 +101,5 @@
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"packageManager": "yarn@4.9.1"
|
||||
"packageManager": "yarn@4.12.0"
|
||||
}
|
||||
|
||||
39
webui/src/components/CopyableText.tsx
Normal file
39
webui/src/components/CopyableText.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CSS, Text } from '@traefiklabs/faency'
|
||||
import { useContext } from 'react'
|
||||
|
||||
import CopyButton from 'components/buttons/CopyButton'
|
||||
import { ToastContext } from 'contexts/toasts'
|
||||
|
||||
type CopyableTextProps = {
|
||||
notifyText?: string
|
||||
text: string
|
||||
css?: CSS
|
||||
}
|
||||
|
||||
export default function CopyableText({ notifyText, text, css }: CopyableTextProps) {
|
||||
const { addToast } = useContext(ToastContext)
|
||||
|
||||
return (
|
||||
<Text
|
||||
css={{
|
||||
flex: '1 1 auto',
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
overflowWrap: 'anywhere',
|
||||
verticalAlign: 'middle',
|
||||
fontSize: 'inherit',
|
||||
...css,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
<CopyButton
|
||||
text={text}
|
||||
onClick={() => {
|
||||
if (notifyText) addToast({ message: notifyText, severity: 'success' })
|
||||
}}
|
||||
css={{ display: 'inline-block', height: 20, verticalAlign: 'middle', ml: '$1' }}
|
||||
iconOnly
|
||||
/>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
13
webui/src/components/ScrollableCard.tsx
Normal file
13
webui/src/components/ScrollableCard.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Card, styled } from '@traefiklabs/faency'
|
||||
|
||||
const ScrollableCard = styled(Card, {
|
||||
width: '100%',
|
||||
maxHeight: 300,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: '$colors-primary $colors-01dp',
|
||||
scrollbarGutter: 'stable',
|
||||
})
|
||||
|
||||
export default ScrollableCard
|
||||
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { FiX } from 'react-icons/fi'
|
||||
|
||||
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
|
||||
import { colorByStatus, iconByStatus } from 'components/resources/Status'
|
||||
|
||||
const CloseButton = styled(Button, {
|
||||
position: 'absolute',
|
||||
@@ -39,7 +39,7 @@ const toastVariants = {
|
||||
}
|
||||
|
||||
export type ToastState = {
|
||||
severity: StatusType
|
||||
severity: Resource.Status
|
||||
message?: string
|
||||
isVisible?: boolean
|
||||
key?: string
|
||||
@@ -88,7 +88,7 @@ export const Toast = ({ message, dismiss, severity = 'error', icon, isVisible =
|
||||
exit="hidden"
|
||||
variants={toastVariants}
|
||||
>
|
||||
<Box css={{ width: '$4', height: '$4' }}>{icon ? icon : propsBySeverity[severity].icon}</Box>
|
||||
<Box css={{ width: '$4', height: '$4', color: 'white' }}>{icon ? icon : propsBySeverity[severity].icon}</Box>
|
||||
<Text css={{ color: 'white', fontWeight: 600, lineHeight: '$4' }}>{message}</Text>
|
||||
{!timeout && (
|
||||
<CloseButton ghost onClick={dismiss} css={{ px: '$2' }}>
|
||||
|
||||
57
webui/src/components/buttons/CopyButton.tsx
Normal file
57
webui/src/components/buttons/CopyButton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Flex, Button, CSS, AccessibleIcon } from '@traefiklabs/faency'
|
||||
import React, { useState } from 'react'
|
||||
import { FiCheck, FiCopy } from 'react-icons/fi'
|
||||
|
||||
type CopyButtonProps = {
|
||||
text: string
|
||||
disabled?: boolean
|
||||
css?: CSS
|
||||
onClick?: () => void
|
||||
iconOnly?: boolean
|
||||
title?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
const CopyButton = ({
|
||||
text,
|
||||
disabled,
|
||||
css,
|
||||
onClick,
|
||||
iconOnly = false,
|
||||
title = 'Copy',
|
||||
color = 'var(--colors-textSubtle)',
|
||||
}: CopyButtonProps) => {
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
|
||||
return (
|
||||
<Button
|
||||
ghost
|
||||
size="small"
|
||||
css={{
|
||||
color: '$hiContrast',
|
||||
px: iconOnly ? '$1' : undefined,
|
||||
...css,
|
||||
}}
|
||||
title={title}
|
||||
onClick={async (e: React.MouseEvent): Promise<void> => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
await navigator.clipboard.writeText(text)
|
||||
if (onClick) onClick()
|
||||
setShowConfirmation(true)
|
||||
setTimeout(() => setShowConfirmation(false), 1500)
|
||||
}}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
<Flex align="center" gap={2} css={{ userSelect: 'none' }}>
|
||||
<AccessibleIcon label="copy">
|
||||
{showConfirmation ? <FiCheck color={color} size={14} /> : <FiCopy color={color} size={14} />}
|
||||
</AccessibleIcon>
|
||||
{!iconOnly ? (showConfirmation ? 'Copied!' : title) : null}
|
||||
</Flex>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default CopyButton
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Box } from '@traefiklabs/faency'
|
||||
import { HTMLAttributes, useMemo } from 'react'
|
||||
|
||||
import Consul from 'components/icons/providers/Consul'
|
||||
@@ -14,13 +15,14 @@ import Nomad from 'components/icons/providers/Nomad'
|
||||
import Plugin from 'components/icons/providers/Plugin'
|
||||
import Redis from 'components/icons/providers/Redis'
|
||||
import Zookeeper from 'components/icons/providers/Zookeeper'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
|
||||
export type ProviderIconProps = HTMLAttributes<SVGElement> & {
|
||||
height?: number | string
|
||||
width?: number | string
|
||||
}
|
||||
|
||||
export default function ProviderIcon({ name, size = 32 }: { name: string; size?: number }) {
|
||||
export default function ProviderIcon({ name, size = 20 }: { name: string; size?: number }) {
|
||||
const Icon = useMemo(() => {
|
||||
if (!name || typeof name !== 'string') return Internal
|
||||
|
||||
@@ -76,3 +78,13 @@ export default function ProviderIcon({ name, size = 32 }: { name: string; size?:
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const ProviderIconWithTooltip = ({ provider, size = 20 }) => {
|
||||
return (
|
||||
<Tooltip label={provider}>
|
||||
<Box css={{ width: size, height: size }}>
|
||||
<ProviderIcon name={provider} size={size} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
40
webui/src/components/middlewares/MiddlewareDefinition.tsx
Normal file
40
webui/src/components/middlewares/MiddlewareDefinition.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ProviderName } from 'components/resources/DetailItemComponents'
|
||||
import DetailsCard from 'components/resources/DetailsCard'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { parseMiddlewareType } from 'libs/parsers'
|
||||
|
||||
type MiddlewareDefinitionProps = {
|
||||
data: Middleware.Details
|
||||
testId?: string
|
||||
}
|
||||
|
||||
const MiddlewareDefinition = ({ data, testId }: MiddlewareDefinitionProps) => {
|
||||
const providerName = useMemo(() => {
|
||||
return data.provider
|
||||
}, [data.provider])
|
||||
|
||||
const detailsItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
data.status && { key: 'Status', val: <ResourceStatus status={data.status} withLabel /> },
|
||||
(data.type || data.plugin) && { key: 'Type', val: parseMiddlewareType(data) },
|
||||
data.provider && {
|
||||
key: 'Provider',
|
||||
val: (
|
||||
<>
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{providerName}</ProviderName>
|
||||
</>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement }[],
|
||||
[data, providerName],
|
||||
)
|
||||
|
||||
return <DetailsCard items={detailsItems} testId={testId} />
|
||||
}
|
||||
|
||||
export default MiddlewareDefinition
|
||||
100
webui/src/components/middlewares/MiddlewareDetail.tsx
Normal file
100
webui/src/components/middlewares/MiddlewareDetail.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Card, Flex, H1, Skeleton, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import MiddlewareDefinition from './MiddlewareDefinition'
|
||||
import { RenderUnknownProp } from './RenderUnknownProp'
|
||||
|
||||
import { DetailsCardSkeleton } from 'components/resources/DetailsCard'
|
||||
import ResourceErrors, { ResourceErrorsSkeleton } from 'components/resources/ResourceErrors'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type MiddlewareDetailProps = {
|
||||
data?: Resource.DetailsData
|
||||
error?: Error | null
|
||||
name: string
|
||||
protocol: 'http' | 'tcp'
|
||||
}
|
||||
|
||||
const filterMiddlewareProps = (middleware: Middleware.Details): string[] => {
|
||||
const filteredProps = [] as string[]
|
||||
const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers']
|
||||
|
||||
Object.keys(middleware).map((propName) => {
|
||||
if (!propsToRemove.includes(propName)) {
|
||||
filteredProps.push(propName)
|
||||
}
|
||||
})
|
||||
|
||||
return filteredProps
|
||||
}
|
||||
|
||||
export const MiddlewareDetail = ({ data, error, name, protocol }: MiddlewareDetailProps) => {
|
||||
const filteredProps = useMemo(() => {
|
||||
if (data) {
|
||||
return filterMiddlewareProps(data)
|
||||
}
|
||||
|
||||
return []
|
||||
}, [data])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$7' }} data-testid="skeleton" />
|
||||
<Flex direction="column" gap={6}>
|
||||
<DetailsCardSkeleton />
|
||||
<ResourceErrorsSkeleton />
|
||||
<UsedByRoutersSkeleton />
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<Flex direction="column" gap={6}>
|
||||
<MiddlewareDefinition data={data} testId="middleware-card" />
|
||||
{!!data.error && <ResourceErrors errors={data.error} />}
|
||||
{(!!data.plugin || !!filteredProps.length) && (
|
||||
<Card>
|
||||
{data.plugin &&
|
||||
Object.keys(data.plugin).map((pluginName) => (
|
||||
<RenderUnknownProp key={pluginName} name={pluginName} prop={data.plugin?.[pluginName]} />
|
||||
))}
|
||||
{filteredProps?.map((propName) => (
|
||||
<RenderUnknownProp key={propName} name={propName} prop={data[propName]} removeTitlePrefix={data.type} />
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<UsedByRoutersSection data-testid="routers-table" data={data} protocol={protocol} />
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Text } from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { BooleanState, ItemBlock } from './DetailSections'
|
||||
import GenericTable from './GenericTable'
|
||||
import IpStrategyTable, { IpStrategy } from './IpStrategyTable'
|
||||
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import CopyableText from 'components/CopyableText'
|
||||
import { BooleanState, ItemBlock } from 'components/resources/DetailItemComponents'
|
||||
import GenericTable from 'components/resources/GenericTable'
|
||||
import IpStrategyTable, { IpStrategy } from 'components/resources/IpStrategyTable'
|
||||
|
||||
type RenderUnknownPropProps = {
|
||||
name: string
|
||||
@@ -22,23 +20,19 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno
|
||||
try {
|
||||
if (typeof prop !== 'undefined') {
|
||||
if (typeof prop === 'boolean') {
|
||||
return wrap(<BooleanState enabled={prop} />)
|
||||
return wrap(<BooleanState css={{ fontSize: '$3' }} enabled={prop} />)
|
||||
}
|
||||
|
||||
if (typeof prop === 'string' && ['true', 'false'].includes((prop as string).toLowerCase())) {
|
||||
return wrap(<BooleanState enabled={prop === 'true'} />)
|
||||
return wrap(<BooleanState css={{ fontSize: '$3' }} enabled={prop === 'true'} />)
|
||||
}
|
||||
|
||||
if (['string', 'number'].includes(typeof prop)) {
|
||||
return wrap(
|
||||
<Tooltip label={prop as string} action="copy">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{prop as string}</Text>
|
||||
</Tooltip>,
|
||||
)
|
||||
return wrap(<CopyableText text={prop as string} css={{ fontSize: '$3' }} />)
|
||||
}
|
||||
|
||||
if (JSON.stringify(prop) === '{}') {
|
||||
return wrap(<BooleanState enabled />)
|
||||
return wrap(<BooleanState enabled css={{ fontSize: '$3' }} />)
|
||||
}
|
||||
|
||||
if (prop instanceof Array) {
|
||||
@@ -75,7 +69,7 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Unable to render plugin property:', { name, prop }, { error })
|
||||
console.error('Unable to render plugin property:', { name, prop }, { error })
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -1,53 +0,0 @@
|
||||
import AdditionalFeatures from './AdditionalFeatures'
|
||||
|
||||
import { MiddlewareProps } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<AdditionalFeatures />', () => {
|
||||
it('should render the middleware info', () => {
|
||||
renderWithProviders(<AdditionalFeatures uid="test-key" />)
|
||||
})
|
||||
|
||||
it('should render the middleware info with number', () => {
|
||||
const middlewares: MiddlewareProps[] = [
|
||||
{
|
||||
retry: {
|
||||
attempts: 2,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
||||
|
||||
expect(container.innerHTML).toContain('Retry: Attempts=2')
|
||||
})
|
||||
|
||||
it('should render the middleware info with string', () => {
|
||||
const middlewares: MiddlewareProps[] = [
|
||||
{
|
||||
circuitBreaker: {
|
||||
expression: 'expression',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
||||
|
||||
expect(container.innerHTML).toContain('CircuitBreaker: Expression="expression"')
|
||||
})
|
||||
|
||||
it('should render the middleware info with string', () => {
|
||||
const middlewares: MiddlewareProps[] = [
|
||||
{
|
||||
rateLimit: {
|
||||
burst: 100,
|
||||
average: 100,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
||||
|
||||
expect(container.innerHTML).toContain('RateLimit: Burst=100, Average=100')
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Badge, Box, Text } from '@traefiklabs/faency'
|
||||
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { MiddlewareProps, ValuesMapType } from 'hooks/use-resource-detail'
|
||||
|
||||
function capitalize(word: string): string {
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
}
|
||||
|
||||
function quote(value: string | number): string | number {
|
||||
if (typeof value === 'string') {
|
||||
return `"${value}"`
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
function quoteArray(values: (string | number)[]): (string | number)[] {
|
||||
return values.map(quote)
|
||||
}
|
||||
|
||||
const renderFeatureValues = (valuesMap: ValuesMapType): string => {
|
||||
return Object.entries(valuesMap)
|
||||
.map(([name, value]) => {
|
||||
const capitalizedName = capitalize(name)
|
||||
if (typeof value === 'string') {
|
||||
return [capitalizedName, `"${value}"`].join('=')
|
||||
}
|
||||
|
||||
if (value instanceof Array) {
|
||||
return [capitalizedName, quoteArray(value).join(', ')].join('=')
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return [capitalizedName, `{${renderFeatureValues(value)}}`].join('=')
|
||||
}
|
||||
|
||||
return [capitalizedName, value].join('=')
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
const FeatureMiddleware = ({ middleware }: { middleware: MiddlewareProps }) => {
|
||||
const [name, value] = Object.entries(middleware)[0]
|
||||
const content = `${capitalize(name)}: ${renderFeatureValues(value)}`
|
||||
|
||||
return (
|
||||
<Tooltip label={content} action="copy">
|
||||
<Badge variant="blue" css={{ mr: '$2', mt: '$2' }}>
|
||||
{content}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type AdditionalFeaturesProps = {
|
||||
middlewares?: MiddlewareProps[]
|
||||
uid: string
|
||||
}
|
||||
|
||||
const AdditionalFeatures = ({ middlewares, uid }: AdditionalFeaturesProps) => {
|
||||
return middlewares?.length ? (
|
||||
<Box css={{ mt: '-$2' }}>
|
||||
{middlewares.map((m, idx) => (
|
||||
<FeatureMiddleware key={`${uid}-${idx}`} middleware={m} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Text css={{ fontStyle: 'italic', color: 'hsl(0, 0%, 56%)' }}>No additional features</Text>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdditionalFeatures
|
||||
95
webui/src/components/resources/DetailItemComponents.tsx
Normal file
95
webui/src/components/resources/DetailItemComponents.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { Badge, CSS, Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
import { BsToggleOff, BsToggleOn } from 'react-icons/bs'
|
||||
|
||||
import { colorByStatus } from './Status'
|
||||
|
||||
import CopyButton from 'components/buttons/CopyButton'
|
||||
|
||||
export const ItemTitle = styled(Text, {
|
||||
marginBottom: '$3',
|
||||
color: 'hsl(0, 0%, 56%)',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
textTransform: 'capitalize',
|
||||
wordBreak: 'break-word',
|
||||
})
|
||||
|
||||
const ItemBlockContainer = styled(Flex, {
|
||||
maxWidth: '100%',
|
||||
flexWrap: 'wrap !important',
|
||||
rowGap: '$2',
|
||||
|
||||
// This forces the Tooltips to respect max-width, since we can't define
|
||||
// it directly on the component, and the Chips are automatically covered.
|
||||
span: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
const FlexLimited = styled(Flex, {
|
||||
maxWidth: '100%',
|
||||
margin: '0 -8px -8px 0',
|
||||
span: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
type ChipsType = {
|
||||
items: string[]
|
||||
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
||||
alignment?: 'center' | 'left'
|
||||
}
|
||||
|
||||
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
|
||||
<FlexLimited wrap="wrap">
|
||||
{items.map((item, index) => (
|
||||
<Badge key={index} variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
|
||||
<Flex gap={1} align="center">
|
||||
{item}
|
||||
<CopyButton text={item} iconOnly />
|
||||
</Flex>
|
||||
</Badge>
|
||||
))}
|
||||
</FlexLimited>
|
||||
)
|
||||
|
||||
type ItemBlockType = {
|
||||
title: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ItemBlock = ({ title, children }: ItemBlockType) => (
|
||||
<Flex css={{ flexDirection: 'column', '&:not(:last-child)': { mb: '$5' } }}>
|
||||
<ItemTitle>{title}</ItemTitle>
|
||||
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
export const BooleanState = ({ enabled, css }: { enabled: boolean; css?: CSS }) => (
|
||||
<Flex align="center" gap={2} css={{ color: '$textDefault', ...css }}>
|
||||
{enabled ? (
|
||||
<BsToggleOn color={colorByStatus.enabled} size={24} data-testid={`enabled-true`} />
|
||||
) : (
|
||||
<BsToggleOff color="inherit" size={24} data-testid={`enabled-false`} />
|
||||
)}
|
||||
|
||||
<Text css={{ color: enabled ? colorByStatus.enabled : 'inherit', fontWeight: 600, fontSize: 'inherit' }}>
|
||||
{enabled ? 'True' : 'False'}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
export const ProviderName = styled(Text, {
|
||||
textTransform: 'capitalize',
|
||||
overflowWrap: 'break-word',
|
||||
fontSize: 'inherit !important',
|
||||
})
|
||||
|
||||
export const EmptyPlaceholder = styled(Text, {
|
||||
color: 'hsl(0, 0%, 76%)',
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '1.2',
|
||||
})
|
||||
@@ -1,352 +0,0 @@
|
||||
import { Badge, Box, Card, Flex, H2, styled, Text } from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
import { FiArrowRight, FiToggleLeft, FiToggleRight } from 'react-icons/fi'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { StatusWrapper } from './ResourceStatus'
|
||||
import { colorByStatus } from './Status'
|
||||
|
||||
import Tooltip from 'components/Tooltip'
|
||||
|
||||
const CustomHeading = styled(H2, {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
})
|
||||
|
||||
type SectionHeaderType = {
|
||||
icon?: ReactNode
|
||||
title?: string | undefined
|
||||
}
|
||||
|
||||
export const SectionHeader = ({ icon, title }: SectionHeaderType) => {
|
||||
if (!title) {
|
||||
return (
|
||||
<CustomHeading css={{ mb: '$6' }}>
|
||||
<Box css={{ width: 5, height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1 }} />
|
||||
<Box css={{ width: '50%', maxWidth: '300px', height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1, ml: '$2' }} />
|
||||
</CustomHeading>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomHeading css={{ mb: '$5' }}>
|
||||
{icon ? icon : null}
|
||||
<Text size={6} css={{ ml: '$2' }}>
|
||||
{title}
|
||||
</Text>
|
||||
</CustomHeading>
|
||||
)
|
||||
}
|
||||
|
||||
export const ItemTitle = styled(Text, {
|
||||
marginBottom: '$3',
|
||||
color: 'hsl(0, 0%, 56%)',
|
||||
letterSpacing: '3px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
textAlign: 'left',
|
||||
textTransform: 'uppercase',
|
||||
wordBreak: 'break-word',
|
||||
})
|
||||
|
||||
const SpacedCard = styled(Card, {
|
||||
'& + &': {
|
||||
marginTop: '16px',
|
||||
},
|
||||
})
|
||||
|
||||
const CardDescription = styled(Text, {
|
||||
textAlign: 'left',
|
||||
fontWeight: '700',
|
||||
fontSize: '16px',
|
||||
lineHeight: '16px',
|
||||
wordBreak: 'break-word',
|
||||
})
|
||||
|
||||
const CardListColumnWrapper = styled(Flex, {
|
||||
display: 'flex',
|
||||
})
|
||||
|
||||
const CardListColumn = styled(Flex, {
|
||||
minWidth: 160,
|
||||
maxWidth: '66%',
|
||||
maxHeight: '416px',
|
||||
overflowY: 'auto',
|
||||
p: '$1',
|
||||
})
|
||||
|
||||
const ItemBlockContainer = styled(Flex, {
|
||||
maxWidth: '100%',
|
||||
flexWrap: 'wrap !important',
|
||||
rowGap: '$2',
|
||||
|
||||
// This forces the Tooltips to respect max-width, since we can't define
|
||||
// it directly on the component, and the Chips are automatically covered.
|
||||
span: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
const FlexLink = styled('a', {
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
textDecoration: 'none',
|
||||
})
|
||||
|
||||
type CardType = {
|
||||
title: string
|
||||
description?: string
|
||||
focus?: boolean
|
||||
link?: string
|
||||
}
|
||||
|
||||
type SectionType = SectionHeaderType & {
|
||||
cards?: CardType[] | undefined
|
||||
isLast?: boolean
|
||||
bigDescription?: boolean
|
||||
}
|
||||
const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
|
||||
return (
|
||||
<SpacedCard css={{ p: '$3' }}>
|
||||
<ItemTitle>
|
||||
<Box css={{ height: '12px', bg: '$slate5', borderRadius: 1, mb: '$3', mr: '60%' }} />
|
||||
</ItemTitle>
|
||||
<CardDescription>
|
||||
<Box
|
||||
css={{
|
||||
height: bigDescription ? '22px' : '14px',
|
||||
mr: '20%',
|
||||
bg: '$slate5',
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
</CardDescription>
|
||||
</SpacedCard>
|
||||
)
|
||||
}
|
||||
|
||||
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
|
||||
<SectionHeader icon={icon} title={title} />
|
||||
<CardListColumnWrapper>
|
||||
<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>
|
||||
))}
|
||||
<Box css={{ height: '16px' }}> </Box>
|
||||
</Flex>
|
||||
</CardListColumn>
|
||||
{!isLast && (
|
||||
<Flex css={{ mt: '$5', mx: 'auto' }}>
|
||||
<FiArrowRight color="hsl(0, 0%, 76%)" size={24} />
|
||||
</Flex>
|
||||
)}
|
||||
</CardListColumnWrapper>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const FlexCard = styled(Card, {
|
||||
display: 'flex',
|
||||
flexFlow: 'column',
|
||||
flexGrow: '1',
|
||||
overflowY: 'auto',
|
||||
height: '600px',
|
||||
})
|
||||
|
||||
const NarrowFlexCard = styled(FlexCard, {
|
||||
height: '400px',
|
||||
})
|
||||
|
||||
const ItemTitleSkeleton = styled(Box, {
|
||||
height: '16px',
|
||||
backgroundColor: '$slate5',
|
||||
borderRadius: '3px',
|
||||
})
|
||||
|
||||
const ItemDescriptionSkeleton = styled(Box, {
|
||||
height: '16px',
|
||||
backgroundColor: '$slate5',
|
||||
borderRadius: '3px',
|
||||
})
|
||||
|
||||
type DetailSectionSkeletonType = {
|
||||
narrow?: boolean
|
||||
}
|
||||
|
||||
export const DetailSectionSkeleton = ({ narrow }: DetailSectionSkeletonType) => {
|
||||
const Card = narrow ? NarrowFlexCard : FlexCard
|
||||
|
||||
return (
|
||||
<Flex css={{ flexDirection: 'column' }}>
|
||||
<SectionHeader />
|
||||
<Card css={{ p: '$5' }}>
|
||||
<LayoutTwoCols css={{ mb: '$2' }}>
|
||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols css={{ mb: '$5' }}>
|
||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
||||
</LayoutTwoCols>
|
||||
<Flex css={{ mb: '$2' }}>
|
||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
||||
</Flex>
|
||||
<Flex css={{ mb: '$5' }}>
|
||||
<ItemDescriptionSkeleton css={{ width: '50%' }} />
|
||||
</Flex>
|
||||
<Flex css={{ mb: '$2' }}>
|
||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
||||
</Flex>
|
||||
<Flex css={{ mb: '$5' }}>
|
||||
<ItemDescriptionSkeleton css={{ width: '70%' }} />
|
||||
</Flex>
|
||||
<Flex css={{ mb: '$2' }}>
|
||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
||||
</Flex>
|
||||
<Flex css={{ mb: '$5' }}>
|
||||
<ItemDescriptionSkeleton css={{ width: '50%' }} />
|
||||
</Flex>
|
||||
<LayoutTwoCols css={{ mb: '$2' }}>
|
||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols css={{ mb: '$5' }}>
|
||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
||||
</LayoutTwoCols>
|
||||
</Card>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
type DetailSectionType = SectionHeaderType & {
|
||||
children?: ReactNode
|
||||
noPadding?: boolean
|
||||
narrow?: boolean
|
||||
}
|
||||
|
||||
export const DetailSection = ({ icon, title, children, narrow, noPadding }: DetailSectionType) => {
|
||||
const Card = narrow ? NarrowFlexCard : FlexCard
|
||||
|
||||
return (
|
||||
<Flex css={{ flexDirection: 'column' }}>
|
||||
<SectionHeader icon={icon} title={title} />
|
||||
<Card css={{ padding: noPadding ? 0 : '$5' }}>{children}</Card>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const FlexLimited = styled(Flex, {
|
||||
maxWidth: '100%',
|
||||
margin: '0 -8px -8px 0',
|
||||
span: {
|
||||
maxWidth: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
type ChipsType = {
|
||||
items: string[]
|
||||
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
||||
alignment?: 'center' | 'left'
|
||||
}
|
||||
|
||||
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
|
||||
<FlexLimited wrap="wrap">
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={item} action="copy">
|
||||
<Badge variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
|
||||
{item}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</FlexLimited>
|
||||
)
|
||||
|
||||
type ChipPropsListType = {
|
||||
data: {
|
||||
[key: string]: string
|
||||
}
|
||||
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
||||
}
|
||||
|
||||
export const ChipPropsList = ({ data, variant }: ChipPropsListType) => (
|
||||
<Flex css={{ flexWrap: 'wrap' }}>
|
||||
{Object.entries(data).map((entry: [string, string]) => (
|
||||
<Badge key={entry[0]} variant={variant} css={{ textAlign: 'left', mr: '$2', mb: '$2' }}>
|
||||
{entry[1]}
|
||||
</Badge>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
type ItemBlockType = {
|
||||
title: string
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export const ItemBlock = ({ title, children }: ItemBlockType) => (
|
||||
<Flex css={{ flexDirection: 'column', mb: '$5' }}>
|
||||
<ItemTitle>{title}</ItemTitle>
|
||||
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const LayoutCols = styled(Box, {
|
||||
display: 'grid',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
export const LayoutTwoCols = styled(LayoutCols, {
|
||||
gridTemplateColumns: 'repeat(2, minmax(50%, 1fr))',
|
||||
})
|
||||
|
||||
export const LayoutThreeCols = styled(LayoutCols, {
|
||||
gridTemplateColumns: 'repeat(3, minmax(30%, 1fr))',
|
||||
})
|
||||
|
||||
export const BooleanState = ({ enabled }: { enabled: boolean }) => (
|
||||
<Flex align="center" gap={2}>
|
||||
<StatusWrapper
|
||||
css={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: enabled ? colorByStatus.enabled : colorByStatus.disabled,
|
||||
}}
|
||||
data-testid={`enabled-${enabled}`}
|
||||
>
|
||||
{enabled ? <FiToggleRight color="#fff" size={20} /> : <FiToggleLeft color="#fff" size={20} />}
|
||||
</StatusWrapper>
|
||||
<Text css={{ color: enabled ? colorByStatus.enabled : colorByStatus.disabled, fontWeight: 600 }}>
|
||||
{enabled ? 'True' : 'False'}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
|
||||
export const ProviderName = styled(Text, {
|
||||
textTransform: 'capitalize',
|
||||
overflowWrap: 'break-word',
|
||||
})
|
||||
|
||||
export const EmptyPlaceholder = styled(Text, {
|
||||
color: 'hsl(0, 0%, 76%)',
|
||||
fontSize: '20px',
|
||||
fontWeight: '700',
|
||||
lineHeight: '1.2',
|
||||
})
|
||||
207
webui/src/components/resources/DetailsCard.tsx
Normal file
207
webui/src/components/resources/DetailsCard.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { Card, CSS, Flex, Grid, H2, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { Fragment, ReactNode, useMemo } from 'react'
|
||||
|
||||
import ScrollableCard from 'components/ScrollableCard'
|
||||
import breakpoints from 'utils/breakpoints'
|
||||
|
||||
const StyledText = styled(Text, {
|
||||
fontSize: 'inherit !important',
|
||||
lineHeight: '24px',
|
||||
})
|
||||
|
||||
export const ValText = styled(StyledText, {
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
})
|
||||
|
||||
export const SectionTitle = ({ icon, title }: { icon?: ReactNode; title: string }) => {
|
||||
return (
|
||||
<Flex gap={2} align="center" css={{ color: '$headingDefault' }}>
|
||||
{icon && icon}
|
||||
<H2 css={{ fontSize: '$5' }}>{title}</H2>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
type DetailsCardProps = {
|
||||
css?: CSS
|
||||
keyColumns?: number
|
||||
items: { key: string; val: string | React.ReactElement; stackVertical?: boolean; forceNewRow?: boolean }[]
|
||||
minKeyWidth?: string
|
||||
maxKeyWidth?: string
|
||||
testidPrefix?: string
|
||||
testId?: string
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
scrollable?: boolean
|
||||
}
|
||||
|
||||
export default function DetailsCard({
|
||||
css = {},
|
||||
keyColumns = 2,
|
||||
items,
|
||||
minKeyWidth,
|
||||
maxKeyWidth,
|
||||
testidPrefix = 'definition',
|
||||
testId,
|
||||
title,
|
||||
icon,
|
||||
scrollable = false,
|
||||
}: DetailsCardProps) {
|
||||
const ParentComponent = useMemo(() => {
|
||||
if (scrollable) {
|
||||
return ScrollableCard
|
||||
}
|
||||
return Card
|
||||
}, [scrollable])
|
||||
|
||||
return (
|
||||
<Flex as="section" direction="column" gap={2} css={{ ...css }} data-testid={testId || `${testidPrefix}-section`}>
|
||||
{title ? <SectionTitle icon={icon} title={title} /> : null}
|
||||
<ParentComponent css={{ flex: 1 }}>
|
||||
<Grid
|
||||
css={{
|
||||
gap: '$2 $3',
|
||||
gridTemplateColumns: maxKeyWidth
|
||||
? `repeat(${keyColumns}, minmax(auto, ${maxKeyWidth}) 1fr)`
|
||||
: `repeat(${keyColumns}, auto 1fr)`,
|
||||
[`@media (max-width:${breakpoints.laptop}px)`]: {
|
||||
gridTemplateColumns: maxKeyWidth ? `minmax(auto, ${maxKeyWidth}) 1fr` : 'auto 1fr',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
// Handle forceNewRow props
|
||||
const cellsBeforeThis = items.slice(0, index).reduce((count, prevItem) => {
|
||||
if (prevItem.stackVertical) return count + keyColumns
|
||||
return count + 1
|
||||
}, 0)
|
||||
|
||||
const needsEmptyCell = item.forceNewRow && cellsBeforeThis % keyColumns !== 0
|
||||
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{needsEmptyCell && (
|
||||
<>
|
||||
<div />
|
||||
<div />
|
||||
</>
|
||||
)}
|
||||
{item.stackVertical ? (
|
||||
<Flex direction="column" gap={2} css={{ gridColumn: 'span 2' }}>
|
||||
<StyledText
|
||||
css={{
|
||||
fontWeight: 600,
|
||||
minWidth: minKeyWidth,
|
||||
maxWidth: maxKeyWidth,
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{item.key}
|
||||
</StyledText>
|
||||
{typeof item.val === 'string' ? (
|
||||
<ValText>{item.val}</ValText>
|
||||
) : (
|
||||
<Flex
|
||||
css={{
|
||||
'> *': {
|
||||
height: 'fit-content',
|
||||
},
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{item.val}
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<Grid>
|
||||
{index < keyColumns
|
||||
? items
|
||||
.filter((hiddenItem) => hiddenItem.key != item.key)
|
||||
.map((hiddenItem, jndex) => (
|
||||
<StyledText
|
||||
key={`hidden-${index}-${jndex}`}
|
||||
aria-hidden="true"
|
||||
css={{
|
||||
gridArea: '1 / 1',
|
||||
fontWeight: 600,
|
||||
visibility: 'hidden',
|
||||
maxWidth: maxKeyWidth,
|
||||
}}
|
||||
>
|
||||
{hiddenItem.key}
|
||||
</StyledText>
|
||||
))
|
||||
: null}
|
||||
<StyledText
|
||||
css={{
|
||||
gridArea: '1 / 1',
|
||||
fontWeight: 600,
|
||||
minWidth: minKeyWidth,
|
||||
maxWidth: maxKeyWidth,
|
||||
overflowWrap: 'break-word',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{item.key}
|
||||
</StyledText>
|
||||
</Grid>
|
||||
{typeof item.val === 'string' ? (
|
||||
<ValText css={{ flex: 1 }}>{item.val}</ValText>
|
||||
) : (
|
||||
<Flex
|
||||
align="center"
|
||||
css={{
|
||||
alignSelf: 'start',
|
||||
'> *': {
|
||||
height: 'fit-content',
|
||||
},
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{item.val}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</Grid>
|
||||
</ParentComponent>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailsCardSkeleton({
|
||||
keyColumns = 2,
|
||||
rows = 3,
|
||||
testidPrefix = 'definition',
|
||||
title,
|
||||
icon,
|
||||
}: { rows?: number } & Omit<DetailsCardProps, 'items'>) {
|
||||
return (
|
||||
<Flex as="section" direction="column" gap={2} data-testid={`${testidPrefix}-section-skeleton`}>
|
||||
{title ? <SectionTitle icon={icon} title={title} /> : <Skeleton css={{ height: '$5', width: '150px' }} />}
|
||||
<Card css={{ flex: 1 }}>
|
||||
<Grid
|
||||
css={{
|
||||
gap: '$2 $3',
|
||||
gridTemplateColumns: `repeat(${keyColumns}, auto 1fr)`,
|
||||
[`@media (max-width:${breakpoints.laptop}px)`]: { gridTemplateColumns: 'auto 1fr' },
|
||||
}}
|
||||
>
|
||||
{[...Array(rows * keyColumns)].map((_, idx) => (
|
||||
<Fragment key={idx}>
|
||||
<Skeleton css={{ height: '$5', width: '96px' }} />
|
||||
<Skeleton css={{ height: '$5', width: '192px' }} />
|
||||
</Fragment>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import Status, { StatusType } from './Status'
|
||||
import Status from './Status'
|
||||
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import CopyableText from 'components/CopyableText'
|
||||
|
||||
type GenericTableProps = {
|
||||
items: (number | string)[]
|
||||
status?: StatusType
|
||||
status?: Resource.Status
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
export default function GenericTable({ items, status }: GenericTableProps) {
|
||||
export default function GenericTable({ items, status, copyable = false }: GenericTableProps) {
|
||||
const border = useMemo(() => `1px solid $${status === 'error' ? 'textRed' : 'tableRowBorder'}`, [status])
|
||||
|
||||
return (
|
||||
@@ -19,23 +20,31 @@ export default function GenericTable({ items, status }: GenericTableProps) {
|
||||
{items.map((item, index) => (
|
||||
<AriaTr key={index}>
|
||||
<AriaTd css={{ p: '$2' }}>
|
||||
<Tooltip label={item.toString()} action="copy">
|
||||
<Flex align="start" gap={2} css={{ width: 'fit-content' }}>
|
||||
{status ? (
|
||||
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={16} />
|
||||
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={14} />
|
||||
) : (
|
||||
<Text css={{ fontFamily: 'monospace', mt: '1px', userSelect: 'none' }} variant="subtle">
|
||||
{index}
|
||||
</Text>
|
||||
)}
|
||||
{copyable ? (
|
||||
<CopyableText
|
||||
text={String(item)}
|
||||
css={{
|
||||
fontFamily: status === 'error' ? 'monospace' : undefined,
|
||||
color: status === 'error' ? '$textRed' : 'initial',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
css={{ fontFamily: status === 'error' ? 'monospace' : undefined }}
|
||||
variant={status === 'error' ? 'red' : undefined}
|
||||
>
|
||||
{item}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
))}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Box, Flex, H3, styled, Text } from '@traefiklabs/faency'
|
||||
import { FiLayers } from 'react-icons/fi'
|
||||
|
||||
import { DetailSection, EmptyPlaceholder, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
|
||||
import GenericTable from './GenericTable'
|
||||
import { RenderUnknownProp } from './RenderUnknownProp'
|
||||
import { ResourceStatus } from './ResourceStatus'
|
||||
|
||||
import { EmptyIcon } from 'components/icons/EmptyIcon'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { Middleware, RouterDetailType } from 'hooks/use-resource-detail'
|
||||
import { parseMiddlewareType } from 'libs/parsers'
|
||||
|
||||
const Separator = styled('hr', {
|
||||
border: 'none',
|
||||
background: '$tableRowBorder',
|
||||
margin: '0 0 24px',
|
||||
height: '1px',
|
||||
minHeight: '1px',
|
||||
})
|
||||
|
||||
const filterMiddlewareProps = (middleware: Middleware): string[] => {
|
||||
const filteredProps = [] as string[]
|
||||
const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers']
|
||||
|
||||
Object.keys(middleware).map((propName) => {
|
||||
if (!propsToRemove.includes(propName)) {
|
||||
filteredProps.push(propName)
|
||||
}
|
||||
})
|
||||
|
||||
return filteredProps
|
||||
}
|
||||
|
||||
type RenderMiddlewareProps = {
|
||||
middleware: Middleware
|
||||
withHeader?: boolean
|
||||
}
|
||||
|
||||
export const RenderMiddleware = ({ middleware, withHeader }: RenderMiddlewareProps) => (
|
||||
<Flex key={middleware.name} css={{ flexDirection: 'column' }}>
|
||||
{withHeader && <H3 css={{ mb: '$7', overflowWrap: 'break-word' }}>{middleware.name}</H3>}
|
||||
<LayoutTwoCols>
|
||||
{(middleware.type || middleware.plugin) && (
|
||||
<ItemBlock title="Type">
|
||||
<Text css={{ lineHeight: '32px', overflowWrap: 'break-word' }}>{parseMiddlewareType(middleware)}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{middleware.provider && (
|
||||
<ItemBlock title="Provider">
|
||||
<ProviderIcon name={middleware.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{middleware.provider}</ProviderName>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{middleware.status && (
|
||||
<ItemBlock title="Status">
|
||||
<ResourceStatus status={middleware.status} withLabel />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{middleware.error && (
|
||||
<ItemBlock title="Errors">
|
||||
<GenericTable items={middleware.error} status="error" />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{middleware.plugin &&
|
||||
Object.keys(middleware.plugin).map((pluginName) => (
|
||||
<RenderUnknownProp key={pluginName} name={pluginName} prop={middleware.plugin?.[pluginName]} />
|
||||
))}
|
||||
{filterMiddlewareProps(middleware).map((propName) => (
|
||||
<RenderUnknownProp
|
||||
key={propName}
|
||||
name={propName}
|
||||
prop={middleware[propName]}
|
||||
removeTitlePrefix={middleware.type}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
const MiddlewarePanel = ({ data }: { data: RouterDetailType }) => (
|
||||
<DetailSection icon={<FiLayers size={20} />} title="Middlewares">
|
||||
{data.middlewares ? (
|
||||
data.middlewares.map((middleware, index) => (
|
||||
<Box key={middleware.name}>
|
||||
<RenderMiddleware middleware={middleware} withHeader />
|
||||
{data.middlewares && index < data.middlewares.length - 1 && <Separator />}
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
<Box
|
||||
css={{
|
||||
width: 88,
|
||||
svg: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmptyIcon />
|
||||
</Box>
|
||||
<EmptyPlaceholder css={{ mt: '$3' }}>
|
||||
There are no
|
||||
<br />
|
||||
Middlewares configured
|
||||
</EmptyPlaceholder>
|
||||
</Flex>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
|
||||
export default MiddlewarePanel
|
||||
31
webui/src/components/resources/ResourceErrors.tsx
Normal file
31
webui/src/components/resources/ResourceErrors.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Card, Flex, Skeleton } from '@traefiklabs/faency'
|
||||
import { FiAlertTriangle } from 'react-icons/fi'
|
||||
|
||||
import { SectionTitle } from './DetailsCard'
|
||||
import GenericTable from './GenericTable'
|
||||
|
||||
const ResourceErrors = ({ errors }: { errors: string[] }) => {
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<SectionTitle title="Errors" icon={<FiAlertTriangle color="hsl(347, 100%, 60.0%)" size={20} />} />
|
||||
<Card>
|
||||
<GenericTable items={errors} status="error" copyable />
|
||||
</Card>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export const ResourceErrorsSkeleton = () => {
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<Skeleton css={{ width: 200 }} />
|
||||
<Card css={{ width: '100%', height: 150, gap: '$3', display: 'flex', flexDirection: 'column' }}>
|
||||
{[...Array(4)].map((_, idx) => (
|
||||
<Skeleton key={`1-${idx}`} />
|
||||
))}
|
||||
</Card>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResourceErrors
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { Box, Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
|
||||
import { colorByStatus, iconByStatus } from 'components/resources/Status'
|
||||
|
||||
export const StatusWrapper = styled(Flex, {
|
||||
height: '32px',
|
||||
width: '32px',
|
||||
height: '24px',
|
||||
width: '24px',
|
||||
padding: 0,
|
||||
borderRadius: '4px',
|
||||
})
|
||||
|
||||
type Props = {
|
||||
status: StatusType
|
||||
status: Resource.Status
|
||||
label?: string
|
||||
withLabel?: boolean
|
||||
size?: number
|
||||
}
|
||||
|
||||
type Value = { color: string; icon: ReactNode; label: string }
|
||||
|
||||
export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
||||
const valuesByStatus: { [key in StatusType]: Value } = {
|
||||
export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props) => {
|
||||
const valuesByStatus: { [key in Resource.Status]: Value } = {
|
||||
info: {
|
||||
color: colorByStatus.info,
|
||||
icon: iconByStatus.info,
|
||||
@@ -50,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
||||
icon: iconByStatus.disabled,
|
||||
label: 'Error',
|
||||
},
|
||||
loading: {
|
||||
color: colorByStatus.loading,
|
||||
icon: iconByStatus.loading,
|
||||
label: 'Loading...',
|
||||
},
|
||||
}
|
||||
|
||||
const values = valuesByStatus[status]
|
||||
@@ -59,12 +65,12 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex css={{ alignItems: 'center' }} data-testid={status}>
|
||||
<StatusWrapper css={{ alignItems: 'center', justifyContent: 'center', backgroundColor: values.color }}>
|
||||
{values.icon}
|
||||
</StatusWrapper>
|
||||
<Flex align="center" css={{ width: size, height: size }} data-testid={status}>
|
||||
<Box css={{ color: values.color, width: size, height: size }}>{values.icon}</Box>
|
||||
{withLabel && values.label && (
|
||||
<Text css={{ ml: '$2', color: values.color, fontWeight: 600 }}>{values.label}</Text>
|
||||
<Text css={{ ml: '$2', color: values.color, fontWeight: 600, fontSize: 'inherit !important' }}>
|
||||
{values.label}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { Badge, Text } from '@traefiklabs/faency'
|
||||
import { FiInfo } from 'react-icons/fi'
|
||||
|
||||
import { DetailSection, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
|
||||
import GenericTable from './GenericTable'
|
||||
import { ResourceStatus } from './ResourceStatus'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
|
||||
type Props = {
|
||||
data: ResourceDetailDataType
|
||||
}
|
||||
|
||||
const RouterPanel = ({ data }: Props) => (
|
||||
<DetailSection icon={<FiInfo size={20} />} title="Router Details">
|
||||
<LayoutTwoCols>
|
||||
{data.status && (
|
||||
<ItemBlock title="Status">
|
||||
<ResourceStatus status={data.status} withLabel />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.provider && (
|
||||
<ItemBlock title="Provider">
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{data.provider}</ProviderName>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.priority && (
|
||||
<ItemBlock title="Priority">
|
||||
<Tooltip label={data.priority.toString()} action="copy">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.priority.toString()}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.rule ? (
|
||||
<ItemBlock title="Rule">
|
||||
<Tooltip label={data.rule} action="copy">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.rule}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
) : null}
|
||||
{data.name && (
|
||||
<ItemBlock title="Name">
|
||||
<Tooltip label={data.name} action="copy">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.name}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{!!data.using && data.using && data.using.length > 0 && (
|
||||
<ItemBlock title="Entrypoints">
|
||||
{data.using.map((ep) => (
|
||||
<Tooltip key={ep} label={ep} action="copy">
|
||||
<Badge css={{ mr: '$2' }}>{ep}</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.service && (
|
||||
<ItemBlock title="Service">
|
||||
<Tooltip label={data.service} action="copy">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.service}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.error && (
|
||||
<ItemBlock title="Errors">
|
||||
<GenericTable items={data.error} status="error" />
|
||||
</ItemBlock>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
|
||||
export default RouterPanel
|
||||
@@ -1,49 +1,50 @@
|
||||
import { Box, CSS } from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
import { FiAlertCircle, FiAlertTriangle, FiCheckCircle } from 'react-icons/fi'
|
||||
import { FiAlertCircle, FiAlertTriangle, FiCheckCircle, FiLoader } from 'react-icons/fi'
|
||||
|
||||
export type StatusType = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled'
|
||||
|
||||
export const iconByStatus: { [key in StatusType]: ReactNode } = {
|
||||
info: <FiAlertCircle color="white" size={20} />,
|
||||
success: <FiCheckCircle color="white" size={20} />,
|
||||
warning: <FiAlertCircle color="white" size={20} />,
|
||||
error: <FiAlertTriangle color="white" size={20} />,
|
||||
enabled: <FiCheckCircle color="white" size={20} />,
|
||||
disabled: <FiAlertTriangle color="white" size={20} />,
|
||||
export const iconByStatus: { [key in Resource.Status]: ReactNode } = {
|
||||
info: <FiAlertCircle color="currentColor" size={20} />,
|
||||
success: <FiCheckCircle color="currentColor" size={20} />,
|
||||
warning: <FiAlertCircle color="currentColor" size={20} />,
|
||||
error: <FiAlertTriangle color="currentColor" size={20} />,
|
||||
enabled: <FiCheckCircle color="currentColor" size={20} />,
|
||||
disabled: <FiAlertTriangle color="currentColor" size={20} />,
|
||||
loading: <FiLoader color="currentColor" size={20} />,
|
||||
}
|
||||
|
||||
// Please notice: dark and light colors have the same values.
|
||||
export const colorByStatus: { [key in StatusType]: string } = {
|
||||
export const colorByStatus: { [key in Resource.Status]: string } = {
|
||||
info: 'hsl(220, 67%, 51%)',
|
||||
success: '#30A46C',
|
||||
warning: 'hsl(24 94.0% 50.0%)',
|
||||
error: 'hsl(347, 100%, 60.0%)',
|
||||
enabled: '#30A46C',
|
||||
disabled: 'hsl(347, 100%, 60.0%)',
|
||||
loading: 'hsla(0, 0%, 100%, 0.51)',
|
||||
}
|
||||
|
||||
type StatusProps = {
|
||||
css?: CSS
|
||||
size?: number
|
||||
status: StatusType
|
||||
status: Resource.Status
|
||||
color?: string
|
||||
}
|
||||
|
||||
export default function Status({ css = {}, size = 20, status }: StatusProps) {
|
||||
export default function Status({ css = {}, size = 20, status, color = 'white' }: StatusProps) {
|
||||
const Icon = ({ size }: { size: number }) => {
|
||||
switch (status) {
|
||||
case 'info':
|
||||
return <FiAlertCircle color="white" size={size} />
|
||||
return <FiAlertCircle color={color} size={size} />
|
||||
case 'success':
|
||||
return <FiCheckCircle color="white" size={size} />
|
||||
return <FiCheckCircle color={color} size={size} />
|
||||
case 'warning':
|
||||
return <FiAlertCircle color="white" size={size} />
|
||||
return <FiAlertCircle color={color} size={size} />
|
||||
case 'error':
|
||||
return <FiAlertTriangle color="white" size={size} />
|
||||
return <FiAlertTriangle color={color} size={size} />
|
||||
case 'enabled':
|
||||
return <FiCheckCircle color="white" size={size} />
|
||||
return <FiCheckCircle color={color} size={size} />
|
||||
case 'disabled':
|
||||
return <FiAlertTriangle color="white" size={size} />
|
||||
return <FiAlertTriangle color={color} size={size} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Badge, Box, Flex, Text } from '@traefiklabs/faency'
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
|
||||
import { BooleanState, DetailSection, EmptyPlaceholder, ItemBlock } from './DetailSections'
|
||||
|
||||
import { EmptyIcon } from 'components/icons/EmptyIcon'
|
||||
import { RouterDetailType } from 'hooks/use-resource-detail'
|
||||
|
||||
type Props = {
|
||||
data: RouterDetailType
|
||||
}
|
||||
|
||||
const TlsPanel = ({ data }: Props) => (
|
||||
<DetailSection icon={<FiShield size={20} />} title="TLS">
|
||||
{data.tls ? (
|
||||
<Flex css={{ flexDirection: 'column' }}>
|
||||
<ItemBlock title="TLS">
|
||||
<BooleanState enabled />
|
||||
</ItemBlock>
|
||||
{data.tls.options && (
|
||||
<ItemBlock title="Options">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.options}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
<ItemBlock title="PassThrough">
|
||||
<BooleanState enabled={!!data.tls.passthrough} />
|
||||
</ItemBlock>
|
||||
{data.tls.certResolver && (
|
||||
<ItemBlock title="Certificate Resolver">
|
||||
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.certResolver}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.tls.domains && (
|
||||
<ItemBlock title="Domains">
|
||||
<Flex css={{ flexDirection: 'column' }}>
|
||||
{data.tls.domains?.map((domain) => (
|
||||
<Flex key={domain.main} css={{ flexWrap: 'wrap' }}>
|
||||
<a href={`//${domain.main}`}>
|
||||
<Badge variant="blue" css={{ mr: '$2', mb: '$2', color: '$primary', borderColor: '$primary' }}>
|
||||
{domain.main}
|
||||
</Badge>
|
||||
</a>
|
||||
{domain.sans?.map((sub) => (
|
||||
<a key={sub} href={`//${sub}`}>
|
||||
<Badge css={{ mr: '$2', mb: '$2' }}>{sub}</Badge>
|
||||
</a>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
|
||||
<Box
|
||||
css={{
|
||||
width: 88,
|
||||
svg: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmptyIcon />
|
||||
</Box>
|
||||
<EmptyPlaceholder css={{ mt: '$3' }}>
|
||||
There is no
|
||||
<br />
|
||||
TLS configured
|
||||
</EmptyPlaceholder>
|
||||
</Flex>
|
||||
)}
|
||||
</DetailSection>
|
||||
)
|
||||
|
||||
export default TlsPanel
|
||||
@@ -5,7 +5,7 @@ import { Doughnut } from 'react-chartjs-2'
|
||||
import { FaArrowRightLong } from 'react-icons/fa6'
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
||||
|
||||
import Status, { colorByStatus, StatusType } from './Status'
|
||||
import Status, { colorByStatus } from './Status'
|
||||
|
||||
import { capitalizeFirstLetter } from 'utils/string'
|
||||
|
||||
@@ -58,7 +58,7 @@ export type DataType = {
|
||||
|
||||
const getPercent = (total: number, value: number) => (total > 0 ? ((value * 100) / total).toFixed(0) : 0)
|
||||
|
||||
const STATS_ATTRIBUTES: { status: StatusType; label: string }[] = [
|
||||
const STATS_ATTRIBUTES: { status: Resource.Status; label: string }[] = [
|
||||
{
|
||||
status: 'enabled',
|
||||
label: 'success',
|
||||
@@ -80,7 +80,7 @@ const CustomLegend = ({
|
||||
total,
|
||||
linkTo,
|
||||
}: {
|
||||
status: StatusType
|
||||
status: Resource.Status
|
||||
label: string
|
||||
count: number
|
||||
total: number
|
||||
|
||||
@@ -1,96 +1,24 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTh, AriaThead, AriaTr, Box, Flex, styled } from '@traefiklabs/faency'
|
||||
import { Flex } from '@traefiklabs/faency'
|
||||
import { orderBy } from 'lodash'
|
||||
import { useContext, useEffect, useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { SectionHeader } from 'components/resources/DetailSections'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import { SectionTitle } from './DetailsCard'
|
||||
|
||||
import AriaTableSkeleton from 'components/tables/AriaTableSkeleton'
|
||||
import PaginatedTable from 'components/tables/PaginatedTable'
|
||||
import { ToastContext } from 'contexts/toasts'
|
||||
import { MiddlewareDetailType, ServiceDetailType } from 'hooks/use-resource-detail'
|
||||
import { makeRowRender } from 'pages/http/HttpRouters'
|
||||
|
||||
type UsedByRoutersSectionProps = {
|
||||
data: ServiceDetailType | MiddlewareDetailType
|
||||
data: Service.Details | Middleware.DetailsData
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
const SkeletonContent = styled(Box, {
|
||||
backgroundColor: '$slate5',
|
||||
height: '14px',
|
||||
minWidth: '50px',
|
||||
borderRadius: '4px',
|
||||
margin: '8px',
|
||||
})
|
||||
|
||||
export const UsedByRoutersSkeleton = () => (
|
||||
<Flex css={{ flexDirection: 'column', mt: '40px' }}>
|
||||
<SectionHeader />
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
<AriaTh>
|
||||
<SkeletonContent />
|
||||
</AriaTh>
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>
|
||||
<AriaTr css={{ pointerEvents: 'none' }}>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
<AriaTr css={{ pointerEvents: 'none' }}>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<SkeletonContent />
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
</AriaTbody>
|
||||
</AriaTable>
|
||||
<Flex gap={2} css={{ flexDirection: 'column', mt: '40px' }}>
|
||||
<SectionTitle title="Used by routers" />
|
||||
<AriaTableSkeleton columns={8} />
|
||||
</Flex>
|
||||
)
|
||||
|
||||
@@ -118,29 +46,38 @@ export const UsedByRoutersSection = ({ data, protocol = 'http' }: UsedByRoutersS
|
||||
)
|
||||
}, [addToast, routersNotFound])
|
||||
|
||||
const columns = useMemo((): Array<{
|
||||
key: keyof Router.DetailsData
|
||||
header: string
|
||||
sortable?: boolean
|
||||
width?: string
|
||||
}> => {
|
||||
return [
|
||||
{ key: 'status', header: 'Status', sortable: true, width: '36px' },
|
||||
...(protocol !== 'udp' ? [{ key: 'tls' as keyof Router.DetailsData, header: 'TLS', width: '24px' }] : []),
|
||||
...(protocol !== 'udp' ? [{ key: 'rule' as keyof Router.DetailsData, header: 'Rule', sortable: true }] : []),
|
||||
{ key: 'using', header: 'Entrypoints', sortable: true },
|
||||
{ key: 'name', header: 'Name', sortable: true },
|
||||
{ key: 'service', header: 'Service', sortable: true },
|
||||
{ key: 'provider', header: 'Provider', sortable: true, width: '40px' },
|
||||
{ key: 'priority', header: 'Priority', sortable: true },
|
||||
]
|
||||
}, [protocol])
|
||||
|
||||
if (!routersFound || routersFound.length <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex css={{ flexDirection: 'column', mt: '$5' }}>
|
||||
<SectionHeader title="Used by Routers" />
|
||||
|
||||
<AriaTable data-testid="routers-table">
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
{protocol !== 'udp' ? <SortableTh css={{ width: '40px' }} label="TLS" /> : null}
|
||||
{protocol !== 'udp' ? <SortableTh label="Rule" isSortable sortByValue="rule" /> : null}
|
||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Service" isSortable sortByValue="service" />
|
||||
<SortableTh label="Provider" css={{ width: '40px' }} isSortable sortByValue="provider" />
|
||||
<SortableTh label="Priority" isSortable sortByValue="priority" />
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>{routersFound.map(renderRow)}</AriaTbody>
|
||||
</AriaTable>
|
||||
<Flex gap={2} css={{ flexDirection: 'column' }}>
|
||||
<SectionTitle title="Used by routers" />
|
||||
<PaginatedTable
|
||||
data={routersFound}
|
||||
columns={columns}
|
||||
itemsPerPage={10}
|
||||
testId="routers-table"
|
||||
renderRow={renderRow}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
67
webui/src/components/routers/RouterDetail.tsx
Normal file
67
webui/src/components/routers/RouterDetail.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Flex, H1, Skeleton, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import { DetailsCardSkeleton } from 'components/resources/DetailsCard'
|
||||
import ResourceErrors, { ResourceErrorsSkeleton } from 'components/resources/ResourceErrors'
|
||||
import RouterFlowDiagram, { RouterFlowDiagramSkeleton } from 'components/routers/RouterFlowDiagram'
|
||||
import TlsSection from 'components/routers/TlsSection'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type RouterDetailProps = {
|
||||
data?: Resource.DetailsData
|
||||
error?: Error | null
|
||||
name: string
|
||||
protocol: 'http' | 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
export const RouterDetail = ({ data, error, name, protocol }: RouterDetailProps) => {
|
||||
const isUdp = useMemo(() => protocol === 'udp', [protocol])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$7' }} data-testid="skeleton" />
|
||||
<Flex direction="column" gap={6}>
|
||||
<RouterFlowDiagramSkeleton />
|
||||
<ResourceErrorsSkeleton />
|
||||
<DetailsCardSkeleton />
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<Flex direction="column" gap={6}>
|
||||
<RouterFlowDiagram data={data} protocol={protocol} />
|
||||
{data?.error && <ResourceErrors errors={data.error} />}
|
||||
{!isUdp && <TlsSection data={data?.tls} />}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
212
webui/src/components/routers/RouterFlowDiagram.tsx
Normal file
212
webui/src/components/routers/RouterFlowDiagram.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { Card, Flex, styled, Link, Tooltip, Box, Text, Skeleton } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { FiArrowRight, FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi'
|
||||
|
||||
import CopyableText from 'components/CopyableText'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ProviderName } from 'components/resources/DetailItemComponents'
|
||||
import DetailsCard, { SectionTitle } from 'components/resources/DetailsCard'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import ScrollableCard from 'components/ScrollableCard'
|
||||
import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
const FlexContainer = styled(Flex, {
|
||||
gap: '$3',
|
||||
flexDirection: 'column !important',
|
||||
alignItems: 'center !important',
|
||||
flex: '1 1 0',
|
||||
minWidth: '0',
|
||||
maxWidth: '100%',
|
||||
})
|
||||
|
||||
const ArrowSeparator = () => {
|
||||
return (
|
||||
<Flex css={{ color: '$textSubtle' }}>
|
||||
<FiArrowRight size={20} />
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const LinkedNameAndStatus = ({ data }: { data: { status: Resource.Status; name: string; href?: string } }) => {
|
||||
const hrefWithReturnTo = useHrefWithReturnTo(data?.href || '')
|
||||
|
||||
if (!data.href) {
|
||||
return (
|
||||
<Flex gap={2} css={{ minWidth: 0, flex: 1 }}>
|
||||
<Tooltip content="Service not found">
|
||||
<Box>
|
||||
<ResourceStatus status={data.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Text
|
||||
css={{
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
fontSize: '$4',
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Flex gap={2} css={{ minWidth: 0, flex: 1 }}>
|
||||
<ResourceStatus status={data.status} />
|
||||
<Link
|
||||
data-testid={data.href}
|
||||
href={hrefWithReturnTo}
|
||||
css={{
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'anywhere',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{data.name}
|
||||
</Link>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
type RouterFlowDiagramProps = {
|
||||
data: Resource.DetailsData
|
||||
protocol: 'http' | 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
const RouterFlowDiagram = ({ data, protocol }: RouterFlowDiagramProps) => {
|
||||
const displayedEntrypoints = useMemo(() => {
|
||||
return data?.entryPointsData?.map((point) => {
|
||||
if (!point.message) {
|
||||
return { key: point.name, val: point.address }
|
||||
} else {
|
||||
return { key: point.message, val: '' }
|
||||
}
|
||||
})
|
||||
}, [data?.entryPointsData])
|
||||
|
||||
const routerDetailsItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
data.status && { key: 'Status', val: <ResourceStatus status={data.status} withLabel /> },
|
||||
data.provider && {
|
||||
key: 'Provider',
|
||||
val: (
|
||||
<>
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{data.provider}</ProviderName>
|
||||
</>
|
||||
),
|
||||
},
|
||||
data.priority && { key: 'Priority', val: data.priority },
|
||||
data.rule && { key: 'Rule', val: <CopyableText css={{ lineHeight: 1.2 }} text={data.rule} /> },
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement }[],
|
||||
[data.priority, data.provider, data.rule, data.status],
|
||||
)
|
||||
|
||||
const serviceSlug = data.service?.includes('@')
|
||||
? data.service
|
||||
: `${data.service ?? 'unknown'}@${data.provider ?? 'unknown'}`
|
||||
|
||||
const { data: serviceData, error: serviceDataError } = useResourceDetail(serviceSlug ?? '', 'services')
|
||||
|
||||
return (
|
||||
<Flex gap={2} data-testid="router-structure">
|
||||
{!!data.using?.length && (
|
||||
<>
|
||||
<FlexContainer>
|
||||
<SectionTitle icon={<FiLogIn size={20} />} title="Entrypoints" />
|
||||
{displayedEntrypoints?.length ? (
|
||||
<DetailsCard
|
||||
css={{ width: '100%' }}
|
||||
items={displayedEntrypoints}
|
||||
keyColumns={1}
|
||||
maxKeyWidth="70%"
|
||||
scrollable
|
||||
/>
|
||||
) : (
|
||||
<DiagramCardSkeleton />
|
||||
)}
|
||||
</FlexContainer>
|
||||
|
||||
<ArrowSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<FlexContainer data-testid="router-details">
|
||||
<SectionTitle icon={<FiGlobe size={20} />} title={`${protocol.toUpperCase()} Router`} />
|
||||
<DetailsCard css={{ width: '100%' }} items={routerDetailsItems} keyColumns={1} scrollable />
|
||||
</FlexContainer>
|
||||
|
||||
{data.hasValidMiddlewares && (
|
||||
<>
|
||||
<ArrowSeparator />
|
||||
<FlexContainer>
|
||||
<SectionTitle icon={<FiLayers size={20} />} title={`${protocol.toUpperCase()} Middlewares`} />
|
||||
{data.middlewares ? (
|
||||
<ScrollableCard>
|
||||
<Flex direction="column" gap={3}>
|
||||
{data.middlewares.map((mw, idx) => {
|
||||
const data = {
|
||||
name: mw.name,
|
||||
status: mw.status,
|
||||
href: `/${protocol}/middlewares/${mw.name}`,
|
||||
}
|
||||
return <LinkedNameAndStatus key={`mw-${idx}`} data={data} />
|
||||
})}
|
||||
</Flex>
|
||||
</ScrollableCard>
|
||||
) : (
|
||||
<DiagramCardSkeleton />
|
||||
)}
|
||||
</FlexContainer>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ArrowSeparator />
|
||||
|
||||
<FlexContainer>
|
||||
<SectionTitle icon={<FiZap size={20} />} title="Service" />
|
||||
<Card css={{ width: '100%' }}>
|
||||
<LinkedNameAndStatus
|
||||
data={{
|
||||
name: data.service as string,
|
||||
status: !serviceDataError ? (serviceData?.status ?? 'loading') : 'disabled',
|
||||
href: !serviceDataError ? `/${protocol}/services/${serviceSlug}` : undefined,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</FlexContainer>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
const DiagramCardSkeleton = () => {
|
||||
return (
|
||||
<Card css={{ width: '100%', height: 200, gap: '$3', display: 'flex', flexDirection: 'column' }}>
|
||||
{[...Array(5)].map((_, idx) => (
|
||||
<Skeleton key={`1-${idx}`} />
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export const RouterFlowDiagramSkeleton = () => {
|
||||
return (
|
||||
<Flex gap={4}>
|
||||
{[...Array(4)].map((_, index) => [
|
||||
<FlexContainer key={`container-${index}`}>
|
||||
<Skeleton css={{ width: 100 }} />
|
||||
<DiagramCardSkeleton />
|
||||
</FlexContainer>,
|
||||
index < 3 && <ArrowSeparator key={`separator-${index}`} />,
|
||||
])}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouterFlowDiagram
|
||||
7
webui/src/components/routers/TlsIcon.tsx
Normal file
7
webui/src/components/routers/TlsIcon.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
|
||||
const TlsIcon = ({ size = 20 }: { size?: number }) => {
|
||||
return <FiShield color="rgb(48, 164, 108)" size={size} />
|
||||
}
|
||||
|
||||
export default TlsIcon
|
||||
78
webui/src/components/routers/TlsSection.tsx
Normal file
78
webui/src/components/routers/TlsSection.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Badge, Box, Card, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import TlsIcon from './TlsIcon'
|
||||
|
||||
import { EmptyIcon } from 'components/icons/EmptyIcon'
|
||||
import { BooleanState, EmptyPlaceholder } from 'components/resources/DetailItemComponents'
|
||||
import DetailsCard, { SectionTitle } from 'components/resources/DetailsCard'
|
||||
|
||||
type Props = {
|
||||
data?: Router.TLS
|
||||
}
|
||||
|
||||
const TlsSection = ({ data }: Props) => {
|
||||
const items = useMemo(() => {
|
||||
if (data) {
|
||||
return [
|
||||
data?.options && { key: 'Options', val: data.options },
|
||||
{ key: 'Passthrough', val: <BooleanState enabled={!!data.passthrough} /> },
|
||||
data?.certResolver && { key: 'Certificate resolver', val: data.certResolver },
|
||||
data?.domains && {
|
||||
stackVertical: true,
|
||||
forceNewRow: true,
|
||||
key: 'Domains',
|
||||
val: (
|
||||
<Flex css={{ flexDirection: 'column' }}>
|
||||
{data.domains?.map((domain) => (
|
||||
<Flex key={domain.main} css={{ flexWrap: 'wrap' }}>
|
||||
<a href={`//${domain.main}`}>
|
||||
<Badge variant="blue" css={{ mr: '$2', mb: '$2', color: '$primary', borderColor: '$primary' }}>
|
||||
{domain.main}
|
||||
</Badge>
|
||||
</a>
|
||||
{domain.sans?.map((sub) => (
|
||||
<a key={sub} href={`//${sub}`}>
|
||||
<Badge css={{ mr: '$2', mb: '$2' }}>{sub}</Badge>
|
||||
</a>
|
||||
))}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement }[]
|
||||
}
|
||||
}, [data])
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<SectionTitle icon={<TlsIcon />} title="TLS" />
|
||||
{items?.length ? (
|
||||
<DetailsCard items={items} />
|
||||
) : (
|
||||
<Card>
|
||||
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center', py: '$4' }}>
|
||||
<Box
|
||||
css={{
|
||||
width: 56,
|
||||
svg: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmptyIcon />
|
||||
</Box>
|
||||
<EmptyPlaceholder css={{ mt: '$3' }}>
|
||||
There is no
|
||||
<br />
|
||||
TLS configured
|
||||
</EmptyPlaceholder>
|
||||
</Flex>
|
||||
</Card>
|
||||
)}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default TlsSection
|
||||
42
webui/src/components/services/MirrorServices.tsx
Normal file
42
webui/src/components/services/MirrorServices.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Flex, Text } from '@traefiklabs/faency'
|
||||
import { FiGlobe } from 'react-icons/fi'
|
||||
|
||||
import { getProviderFromName } from './Servers'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { SectionTitle } from 'components/resources/DetailsCard'
|
||||
import PaginatedTable from 'components/tables/PaginatedTable'
|
||||
|
||||
type MirrorServicesProps = {
|
||||
mirrors: Service.Mirror[]
|
||||
defaultProvider: string
|
||||
}
|
||||
|
||||
const MirrorServices = ({ mirrors, defaultProvider }: MirrorServicesProps) => {
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<SectionTitle icon={<FiGlobe size={20} />} title="Mirror Services" />
|
||||
<PaginatedTable
|
||||
data={mirrors.map((mirror) => ({
|
||||
name: mirror.name,
|
||||
percent: mirror.percent,
|
||||
provider: getProviderFromName(mirror.name, defaultProvider),
|
||||
}))}
|
||||
columns={[
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'percent', header: 'Percent' },
|
||||
{ key: 'provider', header: 'Provider' },
|
||||
]}
|
||||
testId="mirror-services"
|
||||
renderCell={(key, value) => {
|
||||
if (key === 'provider') {
|
||||
return <ProviderIcon name={value as string} />
|
||||
}
|
||||
return <Text>{value}</Text>
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default MirrorServices
|
||||
93
webui/src/components/services/Servers.tsx
Normal file
93
webui/src/components/services/Servers.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { FiGlobe } from 'react-icons/fi'
|
||||
|
||||
import { SectionTitle } from 'components/resources/DetailsCard'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { colorByStatus } from 'components/resources/Status'
|
||||
import PaginatedTable from 'components/tables/PaginatedTable'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
|
||||
type ServersProps = {
|
||||
data: Service.Details
|
||||
protocol: 'http' | 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
type Server = {
|
||||
url?: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
[server: string]: string
|
||||
}
|
||||
|
||||
function getServerStatusList(data: Service.Details): ServerStatus {
|
||||
const serversList: ServerStatus = {}
|
||||
|
||||
data.loadBalancer?.servers?.forEach((server: Server) => {
|
||||
const serverKey = server.address || server.url
|
||||
if (serverKey) {
|
||||
serversList[serverKey] = 'DOWN'
|
||||
}
|
||||
})
|
||||
|
||||
if (data.serverStatus) {
|
||||
Object.entries(data.serverStatus).forEach(([server, status]) => {
|
||||
serversList[server] = status
|
||||
})
|
||||
}
|
||||
|
||||
return serversList
|
||||
}
|
||||
|
||||
export const getProviderFromName = (serviceName: string, defaultProvider: string): string => {
|
||||
const [, provider] = serviceName.split('@')
|
||||
return provider || defaultProvider
|
||||
}
|
||||
|
||||
const Servers = ({ data, protocol }: ServersProps) => {
|
||||
const serversList = getServerStatusList(data)
|
||||
|
||||
const isTcp = useMemo(() => protocol === 'tcp', [protocol])
|
||||
const isUdp = useMemo(() => protocol === 'udp', [protocol])
|
||||
|
||||
if (!Object.keys(serversList)?.length) return null
|
||||
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<SectionTitle icon={<FiGlobe size={20} />} title="Servers" />
|
||||
<PaginatedTable
|
||||
data={Object.entries(serversList).map(([server, status]) => ({
|
||||
server,
|
||||
status,
|
||||
}))}
|
||||
columns={[
|
||||
...(isUdp ? [] : [{ key: 'status' as const, header: 'Status' }]),
|
||||
{ key: 'server' as const, header: isTcp ? 'Address' : 'URL' },
|
||||
]}
|
||||
testId="servers-list"
|
||||
renderCell={(key, value) => {
|
||||
if (key === 'status') {
|
||||
return (
|
||||
<Flex align="center" gap={2}>
|
||||
<ResourceStatus status={value === 'UP' ? 'enabled' : 'disabled'} />
|
||||
<Text css={{ color: value === 'UP' ? colorByStatus.success : colorByStatus.disabled }}>{value}</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
if (key === 'server') {
|
||||
return (
|
||||
<Tooltip label={value} action="copy">
|
||||
<Text>{value}</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return <Text>{value}</Text>
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default Servers
|
||||
58
webui/src/components/services/ServiceDefinition.tsx
Normal file
58
webui/src/components/services/ServiceDefinition.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Badge } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { BooleanState, ProviderName } from 'components/resources/DetailItemComponents'
|
||||
import DetailsCard from 'components/resources/DetailsCard'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
|
||||
type ServiceDefinitionProps = {
|
||||
data: Service.Details
|
||||
testId?: string
|
||||
}
|
||||
|
||||
const ServiceDefinition = ({ data, testId }: ServiceDefinitionProps) => {
|
||||
const providerName = useMemo(() => {
|
||||
return data.provider
|
||||
}, [data.provider])
|
||||
|
||||
const detailsItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
data.status && { key: 'Status', val: <ResourceStatus status={data.status} withLabel /> },
|
||||
data.type && { key: 'Type', val: data.type },
|
||||
data.provider && {
|
||||
key: 'Provider',
|
||||
val: (
|
||||
<>
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{providerName}</ProviderName>
|
||||
</>
|
||||
),
|
||||
},
|
||||
data.mirroring &&
|
||||
data.mirroring.service && { key: 'Main service', val: <Badge>{data.mirroring.service}</Badge> },
|
||||
data.loadBalancer?.passHostHeader && {
|
||||
key: 'Pass host header',
|
||||
val: <BooleanState enabled={data.loadBalancer.passHostHeader} />,
|
||||
},
|
||||
data.loadBalancer?.terminationDelay && {
|
||||
key: 'Termination delay',
|
||||
val: `${data.loadBalancer.terminationDelay} ms`,
|
||||
},
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement }[],
|
||||
[
|
||||
data.loadBalancer?.passHostHeader,
|
||||
data.loadBalancer?.terminationDelay,
|
||||
data.mirroring,
|
||||
data.provider,
|
||||
data.status,
|
||||
data.type,
|
||||
providerName,
|
||||
],
|
||||
)
|
||||
|
||||
return <DetailsCard items={detailsItems} testId={testId} />
|
||||
}
|
||||
|
||||
export default ServiceDefinition
|
||||
87
webui/src/components/services/ServiceDetail.tsx
Normal file
87
webui/src/components/services/ServiceDetail.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Box, Flex, H1, Skeleton, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
import MirrorServices from './MirrorServices'
|
||||
import Servers from './Servers'
|
||||
import ServiceDefinition from './ServiceDefinition'
|
||||
import ServiceHealthCheck from './ServiceHealthCheck'
|
||||
import WeightedServices from './WeightedServices'
|
||||
|
||||
import { DetailsCardSkeleton } from 'components/resources/DetailsCard'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import AriaTableSkeleton from 'components/tables/AriaTableSkeleton'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type ServiceDetailProps = {
|
||||
data?: Resource.DetailsData
|
||||
error?: Error
|
||||
name: string
|
||||
protocol: 'http' | 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
export const ServiceDetail = ({ data, error, name, protocol }: ServiceDetailProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$7' }} data-testid="skeleton" />
|
||||
<Flex direction="column" gap={4}>
|
||||
<DetailsCardSkeleton />
|
||||
<DetailsCardSkeleton />
|
||||
|
||||
<Box>
|
||||
<Skeleton css={{ height: '$5', width: '150px', mb: '$2' }} />
|
||||
<AriaTableSkeleton columns={2} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Skeleton css={{ height: '$5', width: '150px', mb: '$2' }} />
|
||||
<AriaTableSkeleton />
|
||||
</Box>
|
||||
|
||||
<UsedByRoutersSkeleton />
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<Flex direction="column" gap={6}>
|
||||
<ServiceDefinition data={data} testId="service-details" />
|
||||
|
||||
{data.loadBalancer?.healthCheck && <ServiceHealthCheck data={data} protocol={protocol} />}
|
||||
{!!data?.weighted?.services?.length && (
|
||||
<WeightedServices services={data.weighted.services} defaultProvider={data.provider} />
|
||||
)}
|
||||
<Servers data={data} protocol={protocol} />
|
||||
{!!data?.mirroring?.mirrors && (
|
||||
<MirrorServices mirrors={data.mirroring?.mirrors} defaultProvider={data.provider} />
|
||||
)}
|
||||
<UsedByRoutersSection data={data} protocol={protocol} />
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
66
webui/src/components/services/ServiceHealthCheck.tsx
Normal file
66
webui/src/components/services/ServiceHealthCheck.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from 'react'
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
|
||||
import CopyableText from 'components/CopyableText'
|
||||
import { Chips } from 'components/resources/DetailItemComponents'
|
||||
import DetailsCard from 'components/resources/DetailsCard'
|
||||
|
||||
type ServiceHealthCheckProps = {
|
||||
data: Service.Details
|
||||
protocol: 'http' | 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
const ServiceHealthCheck = ({ data, protocol }: ServiceHealthCheckProps) => {
|
||||
const isTcp = useMemo(() => protocol === 'tcp', [protocol])
|
||||
|
||||
const healthCheckItems = useMemo(() => {
|
||||
if (data.loadBalancer?.healthCheck) {
|
||||
const healthCheck = data.loadBalancer.healthCheck
|
||||
if (isTcp) {
|
||||
return [
|
||||
healthCheck?.interval && { key: 'Interval', val: healthCheck.interval },
|
||||
healthCheck?.timeout && { key: 'Timeout', val: healthCheck.timeout },
|
||||
healthCheck?.port && { key: 'Port', val: healthCheck.port },
|
||||
healthCheck?.unhealthyInterval && { key: 'Unhealthy interval', val: healthCheck.unhealthyInterval },
|
||||
healthCheck?.send && {
|
||||
key: 'Send',
|
||||
val: <CopyableText text={healthCheck.send} />,
|
||||
},
|
||||
healthCheck?.expect && {
|
||||
key: 'Expect',
|
||||
val: <CopyableText text={healthCheck.expect} />,
|
||||
},
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement; stackVertical?: boolean }[]
|
||||
} else {
|
||||
return [
|
||||
healthCheck?.scheme && { key: 'Scheme', val: healthCheck.scheme },
|
||||
healthCheck?.interval && { key: 'Interval', val: healthCheck.interval },
|
||||
healthCheck?.path && {
|
||||
key: 'Path',
|
||||
val: <CopyableText text={data.loadBalancer.healthCheck.path} />,
|
||||
},
|
||||
healthCheck?.timeout && { key: 'Timeout', val: healthCheck.timeout },
|
||||
healthCheck?.port && { key: 'Port', val: String(healthCheck.port) },
|
||||
healthCheck?.hostname && {
|
||||
key: 'Hostname',
|
||||
val: <CopyableText text={data.loadBalancer.healthCheck.hostname} />,
|
||||
},
|
||||
healthCheck.headers && {
|
||||
key: 'Headers',
|
||||
val: <Chips variant="neon" items={Object.entries(healthCheck.headers).map((entry) => entry.join(': '))} />,
|
||||
stackVertical: true,
|
||||
forceNewRow: true,
|
||||
},
|
||||
].filter(Boolean) as { key: string; val: string | React.ReactElement; stackVertical?: boolean }[]
|
||||
}
|
||||
}
|
||||
}, [data.loadBalancer?.healthCheck, isTcp])
|
||||
|
||||
if (!healthCheckItems) return null
|
||||
|
||||
return (
|
||||
<DetailsCard icon={<FiShield size={20} />} title="Health Check" items={healthCheckItems} testId="health-check" />
|
||||
)
|
||||
}
|
||||
|
||||
export default ServiceHealthCheck
|
||||
42
webui/src/components/services/WeightedServices.tsx
Normal file
42
webui/src/components/services/WeightedServices.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Flex } from '@traefiklabs/faency'
|
||||
import { FiGlobe } from 'react-icons/fi'
|
||||
|
||||
import { getProviderFromName } from './utils'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { SectionTitle } from 'components/resources/DetailsCard'
|
||||
import PaginatedTable from 'components/tables/PaginatedTable'
|
||||
|
||||
type WeightedServicesProps = {
|
||||
services: Service.WeightedService[]
|
||||
defaultProvider: string
|
||||
}
|
||||
|
||||
const WeightedServices = ({ services, defaultProvider }: WeightedServicesProps) => {
|
||||
return (
|
||||
<Flex direction="column" gap={2}>
|
||||
<SectionTitle icon={<FiGlobe size={20} />} title="Services" />
|
||||
<PaginatedTable
|
||||
data={services.map((service) => ({
|
||||
name: service.name,
|
||||
weight: service.weight,
|
||||
provider: getProviderFromName(service.name, defaultProvider),
|
||||
}))}
|
||||
columns={[
|
||||
{ key: 'name', header: 'Name' },
|
||||
{ key: 'weight', header: 'Weight' },
|
||||
{ key: 'provider', header: 'Provider' },
|
||||
]}
|
||||
testId="weighted-services"
|
||||
renderCell={(key, value) => {
|
||||
if (key === 'provider') {
|
||||
return <ProviderIcon name={value as string} />
|
||||
}
|
||||
return value
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default WeightedServices
|
||||
4
webui/src/components/services/utils.ts
Normal file
4
webui/src/components/services/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const getProviderFromName = (serviceName: string, defaultProvider: string): string => {
|
||||
const [, provider] = serviceName.split('@')
|
||||
return provider || defaultProvider
|
||||
}
|
||||
79
webui/src/components/tables/AriaTableSkeleton.tsx
Normal file
79
webui/src/components/tables/AriaTableSkeleton.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
AriaTable,
|
||||
AriaTbody,
|
||||
AriaTr,
|
||||
CSS,
|
||||
AriaTd,
|
||||
Flex,
|
||||
Skeleton as FaencySkeleton,
|
||||
VariantProps,
|
||||
AriaThead,
|
||||
AriaTh,
|
||||
} from '@traefiklabs/faency'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type AriaTableSkeletonProps = {
|
||||
children?: ReactNode
|
||||
columns?: number
|
||||
css?: CSS
|
||||
lastColumnIsNarrow?: boolean
|
||||
rowHeight?: string
|
||||
rows?: number
|
||||
skeletonWidth?: string
|
||||
}
|
||||
|
||||
interface AriaTdSkeletonProps extends VariantProps<typeof FaencySkeleton> {
|
||||
css?: CSS
|
||||
flexCss?: CSS
|
||||
}
|
||||
const AriaTdSkeleton = ({ css = {}, flexCss = {} }: AriaTdSkeletonProps) => (
|
||||
<AriaTd css={{ height: 38 }}>
|
||||
<Flex css={{ flexDirection: 'column', justifyContent: 'space-around', alignItems: 'flex-start', ...flexCss }}>
|
||||
<FaencySkeleton variant="text" css={css} />
|
||||
</Flex>
|
||||
</AriaTd>
|
||||
)
|
||||
|
||||
const AriaThSkeleton = ({ css = {}, flexCss = {} }: AriaTdSkeletonProps) => (
|
||||
<AriaTh css={{ height: 38 }}>
|
||||
<Flex css={{ flexDirection: 'column', justifyContent: 'space-around', alignItems: 'flex-start', ...flexCss }}>
|
||||
<FaencySkeleton variant="text" css={css} />
|
||||
</Flex>
|
||||
</AriaTh>
|
||||
)
|
||||
|
||||
export default function AriaTableSkeleton({
|
||||
columns = 3,
|
||||
css,
|
||||
lastColumnIsNarrow = false,
|
||||
rowHeight = undefined,
|
||||
rows = 5,
|
||||
skeletonWidth = '50%',
|
||||
}: AriaTableSkeletonProps) {
|
||||
return (
|
||||
<AriaTable css={{ tableLayout: 'auto', ...css }}>
|
||||
<AriaThead>
|
||||
<AriaTr key="header-row">
|
||||
{[...Array(columns)].map((_, colIdx) => (
|
||||
<AriaThSkeleton
|
||||
key={`header-col-${colIdx}`}
|
||||
css={{ width: colIdx === columns - 1 && lastColumnIsNarrow ? '24px' : skeletonWidth }}
|
||||
/>
|
||||
))}
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>
|
||||
{[...Array(rows)].map((_, rowIdx) => (
|
||||
<AriaTr key={`row-${rowIdx}`} css={{ height: rowHeight }}>
|
||||
{[...Array(columns)].map((_, colIdx) => (
|
||||
<AriaTdSkeleton
|
||||
key={`row-${rowIdx}-col-${colIdx}`}
|
||||
css={{ width: colIdx === columns - 1 && lastColumnIsNarrow ? '24px' : skeletonWidth }}
|
||||
/>
|
||||
))}
|
||||
</AriaTr>
|
||||
))}
|
||||
</AriaTbody>
|
||||
</AriaTable>
|
||||
)
|
||||
}
|
||||
@@ -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}>
|
||||
153
webui/src/components/tables/PaginatedTable.tsx
Normal file
153
webui/src/components/tables/PaginatedTable.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaThead, AriaTr, Box, Button, Flex, Text } from '@traefiklabs/faency'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { FiChevronLeft, FiChevronRight, FiChevronsLeft, FiChevronsRight } from 'react-icons/fi'
|
||||
|
||||
import SortableTh from './SortableTh'
|
||||
|
||||
type PaginatedTableProps<T extends Record<string, unknown>> = {
|
||||
data: T[]
|
||||
columns: {
|
||||
key: keyof T
|
||||
header: string
|
||||
sortable?: boolean
|
||||
width?: string
|
||||
}[]
|
||||
itemsPerPage?: number
|
||||
testId?: string
|
||||
renderCell?: (key: keyof T, value: T[keyof T], row: T) => ReactNode
|
||||
renderRow?: (row: T) => ReactNode
|
||||
}
|
||||
|
||||
const PaginatedTable = <T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
itemsPerPage = 5,
|
||||
testId,
|
||||
renderCell,
|
||||
renderRow,
|
||||
}: PaginatedTableProps<T>) => {
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const [tableHeight, setTableHeight] = useState<number | undefined>(undefined)
|
||||
const tableRef = useRef<HTMLTableSectionElement>(null)
|
||||
|
||||
const totalPages = Math.ceil(data.length / itemsPerPage)
|
||||
const startIndex = currentPage * itemsPerPage
|
||||
const endIndex = startIndex + itemsPerPage
|
||||
const currentData = data.slice(startIndex, endIndex)
|
||||
|
||||
// Workaround to keep the same height to avoid layout shift
|
||||
useEffect(() => {
|
||||
if (totalPages > 1 && currentPage === 0 && tableRef.current && !tableHeight) {
|
||||
const height = tableRef.current.offsetHeight
|
||||
setTableHeight(height)
|
||||
}
|
||||
}, [totalPages, currentPage, tableHeight])
|
||||
|
||||
const handleFirstPage = () => {
|
||||
setCurrentPage(0)
|
||||
}
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
setCurrentPage((prev) => Math.max(0, prev - 1))
|
||||
}
|
||||
|
||||
const handleNextPage = () => {
|
||||
setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1))
|
||||
}
|
||||
|
||||
const handleLastPage = () => {
|
||||
setCurrentPage(totalPages - 1)
|
||||
}
|
||||
|
||||
const getCellContent = (key: keyof T, value: T[keyof T], row: T) => {
|
||||
if (renderCell) {
|
||||
return renderCell(key, value, row)
|
||||
}
|
||||
return value as ReactNode
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<AriaTable ref={tableRef} css={totalPages > 1 && tableHeight ? { minHeight: `${tableHeight}px` } : undefined}>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
{columns.map((column) => (
|
||||
<SortableTh
|
||||
key={String(column.key)}
|
||||
label={column.header}
|
||||
isSortable={column.sortable}
|
||||
sortByValue={column.sortable ? String(column.key) : undefined}
|
||||
css={column.width ? { width: column.width } : undefined}
|
||||
/>
|
||||
))}
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
|
||||
<AriaTbody data-testid={testId} css={totalPages > 1 && tableHeight ? { verticalAlign: 'top' } : undefined}>
|
||||
{currentData.map((row, rowIndex) => {
|
||||
if (renderRow) {
|
||||
return renderRow(row)
|
||||
}
|
||||
|
||||
const rowContent = (
|
||||
<>
|
||||
{columns?.map((column) => (
|
||||
<AriaTd key={String(column.key)}>{getCellContent(column.key, row[column.key], row)}</AriaTd>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
|
||||
return <AriaTr key={rowIndex}>{rowContent}</AriaTr>
|
||||
})}
|
||||
</AriaTbody>
|
||||
</AriaTable>
|
||||
{totalPages > 1 && (
|
||||
<Flex justify="center" align="center" gap={2} css={{ mt: '$1' }}>
|
||||
<Flex>
|
||||
<Button
|
||||
ghost
|
||||
onClick={handleFirstPage}
|
||||
disabled={currentPage === 0}
|
||||
aria-label="Go to first page"
|
||||
css={{ px: '$1' }}
|
||||
>
|
||||
<FiChevronsLeft aria-label="First page" />
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
onClick={handlePreviousPage}
|
||||
disabled={currentPage === 0}
|
||||
aria-label="Go to previous page"
|
||||
css={{ px: '$1' }}
|
||||
>
|
||||
<FiChevronLeft aria-label="Previous page" />
|
||||
</Button>
|
||||
</Flex>
|
||||
<Text css={{ fontSize: '14px', color: '$textSubtle' }}>
|
||||
Page {currentPage + 1} of {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
ghost
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
aria-label="Go to next page"
|
||||
css={{ px: '$1' }}
|
||||
>
|
||||
<FiChevronRight aria-label="Next page" />
|
||||
</Button>
|
||||
<Button
|
||||
ghost
|
||||
onClick={handleLastPage}
|
||||
disabled={currentPage === totalPages - 1}
|
||||
aria-label="Go to last page"
|
||||
css={{ px: '$1' }}
|
||||
>
|
||||
<FiChevronsRight aria-label="Last page" />
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaginatedTable
|
||||
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: 'UDP 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: 'UDP 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],
|
||||
)
|
||||
}
|
||||
@@ -2,115 +2,8 @@ import useSWR from 'swr'
|
||||
|
||||
import fetchMany from 'libs/fetchMany'
|
||||
|
||||
export type EntryPoint = {
|
||||
name: string
|
||||
address: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type JSONObject = {
|
||||
[x: string]: string | number
|
||||
}
|
||||
export type ValuesMapType = {
|
||||
[key: string]: string | number | JSONObject
|
||||
}
|
||||
|
||||
export type MiddlewareProps = {
|
||||
[prop: string]: ValuesMapType
|
||||
}
|
||||
|
||||
export type Middleware = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type?: string
|
||||
plugin?: Record<string, unknown>
|
||||
error?: string[]
|
||||
routers?: string[]
|
||||
usedBy?: string[]
|
||||
} & MiddlewareProps
|
||||
|
||||
type Router = {
|
||||
name: string
|
||||
service?: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
rule?: string
|
||||
priority?: number
|
||||
provider: string
|
||||
tls?: {
|
||||
options: string
|
||||
certResolver: string
|
||||
domains: TlsDomain[]
|
||||
passthrough: boolean
|
||||
}
|
||||
error?: string[]
|
||||
entryPoints?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type TlsDomain = {
|
||||
main: string
|
||||
sans: string[]
|
||||
}
|
||||
|
||||
export type RouterDetailType = Router & {
|
||||
middlewares?: Middleware[]
|
||||
hasValidMiddlewares?: boolean
|
||||
entryPointsData?: EntryPoint[]
|
||||
using?: string[]
|
||||
}
|
||||
|
||||
type Mirror = {
|
||||
name: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
export type ServiceDetailType = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type: string
|
||||
usedBy?: string[]
|
||||
routers?: Router[]
|
||||
serverStatus?: {
|
||||
[server: string]: string
|
||||
}
|
||||
mirroring?: {
|
||||
service: string
|
||||
mirrors?: Mirror[]
|
||||
}
|
||||
loadBalancer?: {
|
||||
servers?: { url: string }[]
|
||||
passHostHeader?: boolean
|
||||
terminationDelay?: number
|
||||
healthCheck?: {
|
||||
scheme: string
|
||||
path: string
|
||||
port: number
|
||||
interval: string
|
||||
timeout: string
|
||||
hostname: string
|
||||
headers?: {
|
||||
[header: string]: string
|
||||
}
|
||||
}
|
||||
}
|
||||
weighted?: {
|
||||
services?: {
|
||||
name: string
|
||||
weight: number
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
export type MiddlewareDetailType = Middleware & {
|
||||
routers?: Router[]
|
||||
}
|
||||
|
||||
export type ResourceDetailDataType = RouterDetailType & ServiceDetailType & MiddlewareDetailType
|
||||
|
||||
type ResourceDetailType = {
|
||||
data?: ResourceDetailDataType
|
||||
data?: Resource.DetailsData
|
||||
error?: Error
|
||||
}
|
||||
|
||||
@@ -128,7 +21,7 @@ export const useResourceDetail = (name: string, resource: string, protocol = 'ht
|
||||
}
|
||||
|
||||
const firstError = error || entryPointsError || middlewaresError || routersError
|
||||
const validMiddlewares = (middlewares as Middleware[] | undefined)?.filter((mw) => !!mw.name)
|
||||
const validMiddlewares = (middlewares as Middleware.Details[] | undefined)?.filter((mw) => !!mw.name)
|
||||
const hasMiddlewares = validMiddlewares
|
||||
? validMiddlewares.length > 0
|
||||
: routeDetail.middlewares && routeDetail.middlewares.length > 0
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
144
webui/src/layout/navigation/TopNavBar.tsx
Normal file
144
webui/src/layout/navigation/TopNavBar.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
CSS,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
Flex,
|
||||
Link,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@traefiklabs/faency'
|
||||
import { useContext, useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
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 useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
|
||||
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 { 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="space-between" align="center" css={{ mb: '$6', ...css }}>
|
||||
<TopNavBarBackLink />
|
||||
<Flex gap={2} align="center">
|
||||
{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>
|
||||
)}
|
||||
<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'
|
||||
@@ -1,8 +0,0 @@
|
||||
type ObjectWithMessage = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
export const getValidData = <T extends ObjectWithMessage>(data?: T[]): T[] =>
|
||||
data ? data.filter((item) => !item.message) : []
|
||||
export const getErrorData = <T extends ObjectWithMessage>(data?: T[]): T[] =>
|
||||
data ? data.filter((item) => !!item.message) : []
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Middleware } from 'hooks/use-resource-detail'
|
||||
|
||||
export const parseMiddlewareType = (middleware: Middleware): string | undefined => {
|
||||
export const parseMiddlewareType = (middleware: Middleware.Props): string | undefined => {
|
||||
if (middleware.plugin) {
|
||||
const pluginObject = middleware.plugin || {}
|
||||
const [pluginName] = Object.keys(pluginObject)
|
||||
|
||||
@@ -63,6 +63,19 @@
|
||||
},
|
||||
"forwardedHeaders": {},
|
||||
"name": "web-secured"
|
||||
},
|
||||
{
|
||||
"address": ":443",
|
||||
"transport": {
|
||||
"lifeCycle": {
|
||||
"graceTimeOut": 10000000000
|
||||
},
|
||||
"respondingTimeouts": {
|
||||
"idleTimeout": 180000000000
|
||||
}
|
||||
},
|
||||
"forwardedHeaders": {},
|
||||
"name": "web-secured-longer-name"
|
||||
},
|
||||
{
|
||||
"address": ":8100",
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
[
|
||||
{
|
||||
"addPrefix": {
|
||||
"prefix": "/foo"
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": ["web@docker"],
|
||||
"name": "add-foo@docker",
|
||||
"type": "addprefix",
|
||||
"provider": "docker"
|
||||
"provider": "docker",
|
||||
"addPrefix": {
|
||||
"prefix": "/path",
|
||||
"aCustomObject": {
|
||||
"array of arrays": [
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
],
|
||||
"array of objects": [{ "some": "value" }, { "another": "value" }],
|
||||
"array of booleans": [true, false, true],
|
||||
"array of numbers": [10, 100, 1000],
|
||||
"array of strings": ["value1", "value2"]
|
||||
}
|
||||
},
|
||||
"error": ["message 1", "message 2"]
|
||||
},
|
||||
{
|
||||
"redirectScheme": {
|
||||
@@ -55,14 +66,21 @@
|
||||
"addPrefix": {
|
||||
"prefix": "/path",
|
||||
"aCustomObject": {
|
||||
"array of arrays": [[1, 2], [3, 4]],
|
||||
"array of arrays": [
|
||||
[1, 2],
|
||||
[3, 4]
|
||||
],
|
||||
"array of objects": [{ "some": "value" }, { "another": "value" }],
|
||||
"array of booleans": [true, false, true],
|
||||
"array of numbers": [10, 100, 1000],
|
||||
"array of strings": ["value1", "value2"]
|
||||
}
|
||||
},
|
||||
"error": ["message 1", "message 2"],
|
||||
"error": [
|
||||
"message 1",
|
||||
"message 2",
|
||||
"('colorByStatus' export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports"
|
||||
],
|
||||
"status": "enabled",
|
||||
"usedBy": ["foo@docker", "bar@file"],
|
||||
"name": "middleware00@docker",
|
||||
@@ -144,7 +162,10 @@
|
||||
},
|
||||
{
|
||||
"basicAuth": {
|
||||
"users": ["test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP4HxwgUir3HP4EsggP/QNo0"],
|
||||
"users": [
|
||||
"test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/",
|
||||
"test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP4HxwgUir3HP4EsggP/QNo0"
|
||||
],
|
||||
"usersFile": "/etc/foo/my/file/path/.htpasswd",
|
||||
"realm": "Hello you are here",
|
||||
"removeHeader": true,
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
"rule": "Host(`jaeger-v2-example-beta1`)",
|
||||
"status": "enabled",
|
||||
"name": "jaeger_v2-example-beta1@docker",
|
||||
"using": [
|
||||
"web-secured",
|
||||
"web"
|
||||
],
|
||||
"using": ["web-secured", "web"],
|
||||
"priority": 10,
|
||||
"provider": "docker"
|
||||
},
|
||||
@@ -20,6 +17,24 @@
|
||||
],
|
||||
"status": "disabled",
|
||||
"name": "orphan-router@file",
|
||||
"tls": {
|
||||
"options": "foo@file",
|
||||
"certResolver": "acme-dns-challenge",
|
||||
"domains": [
|
||||
{
|
||||
"main": "example.com",
|
||||
"sans": ["foo.example.com", "bar.example.com"]
|
||||
},
|
||||
{
|
||||
"main": "domain.com",
|
||||
"sans": ["foo.domain.com", "bar.domain.com"]
|
||||
},
|
||||
{
|
||||
"main": "my.domain.com",
|
||||
"sans": ["foo.my.domain.com", "bar.my.domain.com"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"middlewares": [
|
||||
"middleware00@docker",
|
||||
"middleware01@docker",
|
||||
@@ -43,20 +58,12 @@
|
||||
"middleware19@docker",
|
||||
"middleware20@docker"
|
||||
],
|
||||
"using": [
|
||||
"web-secured",
|
||||
"web",
|
||||
"traefik",
|
||||
"web2",
|
||||
"web3"
|
||||
],
|
||||
"using": ["web-secured", "web", "traefik", "web2", "web3"],
|
||||
"priority": 30,
|
||||
"provider": "file"
|
||||
},
|
||||
{
|
||||
"entryPoints": [
|
||||
"web-mtls"
|
||||
],
|
||||
"entryPoints": ["web-mtls"],
|
||||
"service": "api3_v2-example-beta1",
|
||||
"rule": "Host(`server`) \u0026\u0026 Path(`/mtls`)",
|
||||
"tls": {
|
||||
@@ -65,24 +72,15 @@
|
||||
"domains": [
|
||||
{
|
||||
"main": "example.com",
|
||||
"sans": [
|
||||
"foo.example.com",
|
||||
"bar.example.com"
|
||||
]
|
||||
"sans": ["foo.example.com", "bar.example.com"]
|
||||
},
|
||||
{
|
||||
"main": "domain.com",
|
||||
"sans": [
|
||||
"foo.domain.com",
|
||||
"bar.domain.com"
|
||||
]
|
||||
"sans": ["foo.domain.com", "bar.domain.com"]
|
||||
},
|
||||
{
|
||||
"main": "my.domain.com",
|
||||
"sans": [
|
||||
"foo.my.domain.com",
|
||||
"bar.my.domain.com"
|
||||
]
|
||||
"sans": ["foo.my.domain.com", "bar.my.domain.com"]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -90,39 +88,27 @@
|
||||
"priority": 42,
|
||||
"name": "server-mtls@docker",
|
||||
"provider": "docker",
|
||||
"using": [
|
||||
"web-mtls"
|
||||
]
|
||||
"using": ["web-mtls"]
|
||||
},
|
||||
{
|
||||
"entryPoints": [
|
||||
"web-redirect"
|
||||
],
|
||||
"middlewares": [
|
||||
"redirect@file"
|
||||
],
|
||||
"entryPoints": ["web-redirect"],
|
||||
"middlewares": ["redirect@file"],
|
||||
"service": "api2_v2-example-beta1",
|
||||
"rule": "Host(`server`)",
|
||||
"status": "enabled",
|
||||
"name": "server-redirect@docker",
|
||||
"using": [
|
||||
"web-redirect"
|
||||
],
|
||||
"using": ["web-redirect"],
|
||||
"priority": 9223372036854776000,
|
||||
"provider": "docker"
|
||||
},
|
||||
{
|
||||
"entryPoints": [
|
||||
"web-secured"
|
||||
],
|
||||
"entryPoints": ["web-secured"],
|
||||
"service": "api2_v2-example-beta1",
|
||||
"rule": "Host(`server`)",
|
||||
"tls": {},
|
||||
"status": "enabled",
|
||||
"name": "server-secured@docker",
|
||||
"using": [
|
||||
"web-secured"
|
||||
],
|
||||
"using": ["web-secured"],
|
||||
"provider": "docker"
|
||||
},
|
||||
{
|
||||
@@ -130,42 +116,27 @@
|
||||
"rule": "Host(`traefik-v2-example-beta1`)",
|
||||
"status": "enabled",
|
||||
"name": "traefik_v2-example-beta1@docker",
|
||||
"using": [
|
||||
"web-secured",
|
||||
"web"
|
||||
],
|
||||
"using": ["web-secured", "web"],
|
||||
"provider": "docker"
|
||||
},
|
||||
{
|
||||
"entryPoints": [
|
||||
"web"
|
||||
],
|
||||
"middlewares": [
|
||||
"add-foo"
|
||||
],
|
||||
"entryPoints": ["web"],
|
||||
"middlewares": ["add-foo"],
|
||||
"service": "api_v2-example-beta1",
|
||||
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
||||
"status": "enabled",
|
||||
"name": "web@docker",
|
||||
"using": [
|
||||
"web"
|
||||
],
|
||||
"using": ["web"],
|
||||
"provider": "docker"
|
||||
},
|
||||
{
|
||||
"entryPoints": [
|
||||
"web"
|
||||
],
|
||||
"middlewares": [
|
||||
"whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware"
|
||||
],
|
||||
"entryPoints": ["web"],
|
||||
"middlewares": ["whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware"],
|
||||
"service": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service",
|
||||
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
||||
"status": "enabled",
|
||||
"name": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a@kubernetescrd",
|
||||
"using": [
|
||||
"web"
|
||||
],
|
||||
"using": ["web"],
|
||||
"provider": "docker"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
"passHostHeader": true
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": [
|
||||
"server-redirect@docker",
|
||||
"server-secured@docker"
|
||||
],
|
||||
"usedBy": ["server-redirect@docker", "server-secured@docker"],
|
||||
"serverStatus": {
|
||||
"http://10.0.1.12:80": "UP"
|
||||
},
|
||||
@@ -95,9 +92,7 @@
|
||||
}
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": [
|
||||
"server-mtls@docker"
|
||||
],
|
||||
"usedBy": ["server-mtls@docker"],
|
||||
"serverStatus": {
|
||||
"http://10.0.1.20:80": "UP",
|
||||
"http://10.0.1.21:80": "UP",
|
||||
@@ -107,8 +102,25 @@
|
||||
"http://10.0.1.25:80": "UP"
|
||||
},
|
||||
"name": "api3_v2-example-beta1@docker",
|
||||
"type": "loadbalancer",
|
||||
"provider": "docker"
|
||||
"type": "mirroring",
|
||||
"provider": "docker",
|
||||
"mirroring": {
|
||||
"mirrors": [
|
||||
{
|
||||
"name": "two@docker",
|
||||
"percent": 10
|
||||
},
|
||||
{
|
||||
"name": "three@docker",
|
||||
"percent": 15
|
||||
},
|
||||
{
|
||||
"name": "four@docker",
|
||||
"percent": 80
|
||||
}
|
||||
],
|
||||
"service": "one@docker"
|
||||
}
|
||||
},
|
||||
{
|
||||
"loadBalancer": {
|
||||
@@ -120,9 +132,7 @@
|
||||
"passHostHeader": true
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": [
|
||||
"web@docker"
|
||||
],
|
||||
"usedBy": ["web@docker"],
|
||||
"serverStatus": {
|
||||
"http://10.0.1.11:80": "UP"
|
||||
},
|
||||
@@ -140,9 +150,7 @@
|
||||
"passHostHeader": true
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": [
|
||||
"jaeger_v2-example-beta1@docker"
|
||||
],
|
||||
"usedBy": ["jaeger_v2-example-beta1@docker"],
|
||||
"serverStatus": {
|
||||
"http://10.0.1.20:5775": "UP"
|
||||
},
|
||||
@@ -174,9 +182,7 @@
|
||||
"passHostHeader": true
|
||||
},
|
||||
"status": "enabled",
|
||||
"usedBy": [
|
||||
"traefik_v2-example-beta1@docker"
|
||||
],
|
||||
"usedBy": ["traefik_v2-example-beta1@docker"],
|
||||
"serverStatus": {
|
||||
"http://10.0.1.10:80": "UP"
|
||||
},
|
||||
@@ -189,9 +195,7 @@
|
||||
"provider": "docker",
|
||||
"status": "enabled",
|
||||
"type": "weighted",
|
||||
"usedBy": [
|
||||
"foo@docker"
|
||||
],
|
||||
"usedBy": ["foo@docker"],
|
||||
"weighted": {
|
||||
"sticky": {
|
||||
"cookie": {
|
||||
@@ -207,9 +211,7 @@
|
||||
"provider": "docker",
|
||||
"status": "enabled",
|
||||
"type": "weighted",
|
||||
"usedBy": [
|
||||
"fii@docker"
|
||||
],
|
||||
"usedBy": ["fii@docker"],
|
||||
"weighted": {
|
||||
"sticky": {
|
||||
"cookie": {}
|
||||
@@ -238,8 +240,6 @@
|
||||
"provider": "docker",
|
||||
"status": "enabled",
|
||||
"type": "mirroring",
|
||||
"usedBy": [
|
||||
"foo@docker"
|
||||
]
|
||||
"usedBy": ["foo@docker"]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"Version": "3.4.0",
|
||||
"Codename": "montdor",
|
||||
"Version": "3.6.0",
|
||||
"Codename": "ramequin",
|
||||
"disableDashboardAd": false,
|
||||
"startDate": "2025-03-28T14:58:25.8937758+01:00"
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { HttpMiddlewareRender } from './HttpMiddleware'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpMiddlewarePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={undefined} error={new Error('Test error')} protocol="http" />,
|
||||
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={undefined} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={{} as Resource.DetailsData} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -55,7 +53,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockMiddleware as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/middleware-simple', withPage: true },
|
||||
)
|
||||
|
||||
@@ -67,12 +65,11 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
expect(middlewareCard.innerHTML).toContain('addprefix')
|
||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('/foo')
|
||||
expect(container.innerHTML).toContain('/foo')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-simple@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test-simple@docker')
|
||||
})
|
||||
|
||||
it('should render a plugin middleware', () => {
|
||||
@@ -102,7 +99,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockMiddleware as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/middleware-plugin', withPage: true },
|
||||
)
|
||||
|
||||
@@ -112,11 +109,11 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-plugin@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test-plugin@docker')
|
||||
})
|
||||
|
||||
it('should render a complex middleware', async () => {
|
||||
@@ -342,7 +339,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockMiddleware as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/middleware-complex', withPage: true },
|
||||
)
|
||||
|
||||
@@ -353,60 +350,50 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('the-provider')
|
||||
expect(middlewareCard.innerHTML).toContain('redirect-scheme')
|
||||
expect(middlewareCard.innerHTML).toContain('add-prefix-sample')
|
||||
expect(middlewareCard.innerHTML).toContain('buffer-retry-expression')
|
||||
expect(middlewareCard.innerHTML).toContain('circuit-breaker')
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['126.0.0.1', '126.0.0.2', 'inflight-req-header'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['125.0.0.1', '125.0.0.2', '125.0.0.3', '125.0.0.4'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['ssl.host', 'ssl-proxy-header-a', 'ssl-proxy-header-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2'])
|
||||
expect(middlewareCard.innerHTML).toContain('allowed.origin')
|
||||
expect(middlewareCard.innerHTML).toContain('custom-frame-options')
|
||||
expect(middlewareCard.innerHTML).toContain('content-security-policy')
|
||||
expect(middlewareCard.innerHTML).toContain('public-key')
|
||||
expect(middlewareCard.innerHTML).toContain('referrer-policy')
|
||||
expect(middlewareCard.innerHTML).toContain('feature-policy')
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
expect(container.innerHTML).toContain('redirect-scheme')
|
||||
expect(container.innerHTML).toContain('add-prefix-sample')
|
||||
expect(container.innerHTML).toContain('buffer-retry-expression')
|
||||
expect(container.innerHTML).toContain('circuit-breaker')
|
||||
expect(container.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['126.0.0.1', '126.0.0.2', 'inflight-req-header'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['125.0.0.1', '125.0.0.2', '125.0.0.3', '125.0.0.4'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['ssl.host', 'ssl-proxy-header-a', 'ssl-proxy-header-b'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2'])
|
||||
expect(container.innerHTML).toContain('allowed.origin')
|
||||
expect(container.innerHTML).toContain('custom-frame-options')
|
||||
expect(container.innerHTML).toContain('content-security-policy')
|
||||
expect(container.innerHTML).toContain('public-key')
|
||||
expect(container.innerHTML).toContain('referrer-policy')
|
||||
expect(container.innerHTML).toContain('feature-policy')
|
||||
expect(container.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b'])
|
||||
expect(container.innerHTML).toIncludeMultiple([
|
||||
'forward-auth-address',
|
||||
'auth-response-header-1',
|
||||
'auth-response-header-2',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
expect(container.innerHTML).toIncludeMultiple([
|
||||
'error-sample',
|
||||
'status-1',
|
||||
'status-2',
|
||||
'errors-service',
|
||||
'errors-query',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'chain-middleware-1',
|
||||
'chain-middleware-2',
|
||||
'chain-middleware-3',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'user1',
|
||||
'user2',
|
||||
'users/file',
|
||||
'realm-sample',
|
||||
'basic-auth-header',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
expect(container.innerHTML).toIncludeMultiple(['chain-middleware-1', 'chain-middleware-2', 'chain-middleware-3'])
|
||||
expect(container.innerHTML).toIncludeMultiple(['user1', 'user2', 'users/file', 'realm-sample', 'basic-auth-header'])
|
||||
expect(container.innerHTML).toIncludeMultiple([
|
||||
'strip-prefix1',
|
||||
'strip-prefix2',
|
||||
'strip-prefix-regex1',
|
||||
'strip-prefix-regex2',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
expect(container.innerHTML).toIncludeMultiple([
|
||||
'10000',
|
||||
'10001',
|
||||
'10002',
|
||||
@@ -421,7 +408,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
'10011',
|
||||
'10012',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
expect(container.innerHTML).toIncludeMultiple([
|
||||
'plugin-ldap-source',
|
||||
'plugin-ldap-base-dn',
|
||||
'plugin-ldap-attribute',
|
||||
@@ -438,9 +425,8 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
])
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-complex@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test-complex@docker')
|
||||
})
|
||||
|
||||
it('should render a plugin middleware with no type', async () => {
|
||||
@@ -464,7 +450,7 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockMiddleware as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/middlewares/middleware-plugin-no-type', withPage: true },
|
||||
)
|
||||
|
||||
@@ -474,15 +460,15 @@ describe('<HttpMiddlewarePage />', () => {
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > negative Grand Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > positive Grand Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > string Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > array Child')
|
||||
expect(container.innerHTML).toContain('jwtAuth > child')
|
||||
expect(container.innerHTML).toContain('jwtAuth > sibling > negative Grand Child')
|
||||
expect(container.innerHTML).toContain('jwtAuth > sibling > positive Grand Child')
|
||||
expect(container.innerHTML).toContain('jwtAuth > string Child')
|
||||
expect(container.innerHTML).toContain('jwtAuth > array Child')
|
||||
|
||||
const childSpans = Array.from(middlewareCard.querySelectorAll('span')).filter((span) =>
|
||||
const childSpans = Array.from(container.querySelectorAll('span')).filter((span) =>
|
||||
['0', '1', '2', '3', '123'].includes(span.innerHTML),
|
||||
)
|
||||
expect(childSpans.length).toBe(7)
|
||||
expect(childSpans.length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +1,12 @@
|
||||
import { Box, Card, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import { RenderMiddleware } from 'components/resources/MiddlewarePanel'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
import breakpoints from 'utils/breakpoints'
|
||||
|
||||
const MiddlewareGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
|
||||
|
||||
[`@media (max-width: ${breakpoints.tablet})`]: {
|
||||
gridTemplateColumns: '1fr',
|
||||
},
|
||||
})
|
||||
|
||||
type HttpMiddlewareRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$4' }} data-testid="skeleton" />
|
||||
<MiddlewareGrid data-testid="skeletons">
|
||||
<DetailSectionSkeleton />
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<MiddlewareGrid>
|
||||
<Card css={{ p: '$3' }} data-testid="middleware-card">
|
||||
<RenderMiddleware middleware={data} />
|
||||
</Card>
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="http" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const HttpMiddleware = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'middlewares')
|
||||
return <HttpMiddlewareRender data={data} error={error} name={name!} />
|
||||
return <MiddlewareDetail data={data} error={error} name={name!} protocol="http" />
|
||||
}
|
||||
|
||||
export default HttpMiddleware
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
return (
|
||||
<ClickableRow key={row.name} to={`/http/middlewares/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
@@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<TooltipText text={middlewareType} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
@@ -69,7 +60,7 @@ export const HttpMiddlewaresRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { HttpRouterRender } from './HttpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import apiEntrypoints from 'mocks/data/api-entrypoints.json'
|
||||
import apiHttpMiddlewares from 'mocks/data/api-http_middlewares.json'
|
||||
import apiHttpRouters from 'mocks/data/api-http_routers.json'
|
||||
@@ -9,7 +7,7 @@ import { renderWithProviders } from 'utils/test'
|
||||
describe('<HttpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={new Error('Test error')} protocol="http" />,
|
||||
{ route: '/http/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -17,7 +15,7 @@ describe('<HttpRouterPage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={undefined} protocol="http" />,
|
||||
{ route: '/http/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -25,7 +23,7 @@ describe('<HttpRouterPage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={{} as Resource.DetailsData} error={undefined} protocol="http" />,
|
||||
{ route: '/http/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -42,7 +40,7 @@ describe('<HttpRouterPage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={mockData as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/routers/orphan-router@file', withPage: true },
|
||||
)
|
||||
|
||||
@@ -52,7 +50,6 @@ describe('<HttpRouterPage />', () => {
|
||||
expect(routerStructure.innerHTML).toContain(':8080')
|
||||
expect(routerStructure.innerHTML).toContain(':8002')
|
||||
expect(routerStructure.innerHTML).toContain(':8003')
|
||||
expect(routerStructure.innerHTML).toContain('orphan-router@file')
|
||||
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||
expect(routerStructure.innerHTML).toContain('middleware02')
|
||||
@@ -78,43 +75,35 @@ describe('<HttpRouterPage />', () => {
|
||||
expect(routerStructure.innerHTML).toContain('HTTP Router')
|
||||
expect(routerStructure.innerHTML).not.toContain('TCP Router')
|
||||
|
||||
const routerDetailsSection = getByTestId('router-detail')
|
||||
const routerDetailsSection = getByTestId('router-details')
|
||||
|
||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('orphan-router@file')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Error')
|
||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||
expect(routerDetailsPanel?.innerHTML).toContain(
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Error')
|
||||
expect(routerDetailsSection?.querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||
expect(routerDetailsSection?.innerHTML).toContain(
|
||||
'Path(`somethingreallyunexpectedbutalsoverylongitgetsoutofthecontainermaybe`)',
|
||||
)
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('unexistingservice')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('the service "unexistingservice@file" does not exist')
|
||||
|
||||
const middlewaresPanel = routerDetailsSection.querySelector(':scope > div:nth-child(3)')
|
||||
const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || [])
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware00')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware01')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware02')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware03')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware04')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware05')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware06')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware07')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware08')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware09')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware10')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware11')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware12')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware13')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware14')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware15')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware16')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware17')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware18')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware19')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware20')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('Success')
|
||||
expect(providers.length).toBe(21)
|
||||
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||
expect(routerStructure.innerHTML).toContain('middleware02')
|
||||
expect(routerStructure.innerHTML).toContain('middleware03')
|
||||
expect(routerStructure.innerHTML).toContain('middleware04')
|
||||
expect(routerStructure.innerHTML).toContain('middleware05')
|
||||
expect(routerStructure.innerHTML).toContain('middleware06')
|
||||
expect(routerStructure.innerHTML).toContain('middleware07')
|
||||
expect(routerStructure.innerHTML).toContain('middleware08')
|
||||
expect(routerStructure.innerHTML).toContain('middleware09')
|
||||
expect(routerStructure.innerHTML).toContain('middleware10')
|
||||
expect(routerStructure.innerHTML).toContain('middleware11')
|
||||
expect(routerStructure.innerHTML).toContain('middleware12')
|
||||
expect(routerStructure.innerHTML).toContain('middleware13')
|
||||
expect(routerStructure.innerHTML).toContain('middleware14')
|
||||
expect(routerStructure.innerHTML).toContain('middleware15')
|
||||
expect(routerStructure.innerHTML).toContain('middleware16')
|
||||
expect(routerStructure.innerHTML).toContain('middleware17')
|
||||
expect(routerStructure.innerHTML).toContain('middleware18')
|
||||
expect(routerStructure.innerHTML).toContain('middleware19')
|
||||
expect(routerStructure.innerHTML).toContain('middleware20')
|
||||
|
||||
expect(getByTestId('/http/middlewares/middleware00@docker')).toBeInTheDocument()
|
||||
|
||||
|
||||
@@ -1,161 +1,13 @@
|
||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { useContext, useEffect, useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import MiddlewarePanel from 'components/resources/MiddlewarePanel'
|
||||
import RouterPanel from 'components/resources/RouterPanel'
|
||||
import TlsPanel from 'components/resources/TlsPanel'
|
||||
import { ToastContext } from 'contexts/toasts'
|
||||
import { EntryPoint, ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { getErrorData, getValidData } from 'libs/objectHandlers'
|
||||
import { parseMiddlewareType } from 'libs/parsers'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
const CardListColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
marginBottom: '48px',
|
||||
})
|
||||
|
||||
type DetailProps = {
|
||||
data: ResourceDetailDataType
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
export const RouterStructure = ({ data, protocol = 'http' }: DetailProps) => {
|
||||
const { addToast } = useContext(ToastContext)
|
||||
const entrypoints = useMemo(() => getValidData(data.entryPointsData), [data?.entryPointsData])
|
||||
const entrypointsError = useMemo(() => getErrorData(data.entryPointsData), [data?.entryPointsData])
|
||||
|
||||
const serviceSlug = data.service?.includes('@')
|
||||
? data.service
|
||||
: `${data.service ?? 'unknown'}@${data.provider ?? 'unknown'}`
|
||||
|
||||
useEffect(() => {
|
||||
entrypointsError?.map((error) =>
|
||||
addToast({
|
||||
message: error.message,
|
||||
severity: 'error',
|
||||
}),
|
||||
)
|
||||
}, [addToast, entrypointsError])
|
||||
|
||||
return (
|
||||
<CardListColumns data-testid="router-structure">
|
||||
{entrypoints.length > 0 && (
|
||||
<CardListSection
|
||||
bigDescription
|
||||
icon={<FiLogIn size={20} />}
|
||||
title="Entrypoints"
|
||||
cards={data.entryPointsData?.map((ep: EntryPoint) => ({
|
||||
title: ep.name,
|
||||
description: ep.address,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<CardListSection
|
||||
icon={<FiGlobe size={20} />}
|
||||
title={`${protocol.toUpperCase()} Router`}
|
||||
cards={[{ title: 'router', description: data.name, focus: true }]}
|
||||
/>
|
||||
{data.hasValidMiddlewares && (
|
||||
<CardListSection
|
||||
icon={<FiLayers size={20} />}
|
||||
title={`${protocol.toUpperCase()} Middlewares`}
|
||||
cards={data.middlewares?.map((mw) => ({
|
||||
title: parseMiddlewareType(mw) ?? 'middleware',
|
||||
description: mw.name,
|
||||
link: `/${protocol}/middlewares/${mw.name}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<CardListSection
|
||||
isLast
|
||||
icon={<FiZap size={20} />}
|
||||
title="Service"
|
||||
cards={[{ title: 'service', description: data.service, link: `/${protocol}/services/${serviceSlug}` }]}
|
||||
/>
|
||||
</CardListColumns>
|
||||
)
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const RouterDetail = ({ data }: DetailProps) => (
|
||||
<SpacedColumns data-testid="router-detail">
|
||||
<RouterPanel data={data} />
|
||||
<TlsPanel data={data} />
|
||||
<MiddlewarePanel data={data} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type HttpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
|
||||
<CardListSection bigDescription />
|
||||
<CardListSection />
|
||||
<CardListSection />
|
||||
<CardListSection isLast />
|
||||
</Flex>
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
</SpacedColumns>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<RouterStructure data={data} protocol="http" />
|
||||
<RouterDetail data={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const HttpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers')
|
||||
return <HttpRouterRender data={data} error={error} name={name!} />
|
||||
|
||||
return <RouterDetail data={data} error={error} name={name!} protocol="http" />
|
||||
}
|
||||
|
||||
export default HttpRouter
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailSections'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailItemComponents'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import TlsIcon from 'components/routers/TlsIcon'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
@@ -22,25 +22,32 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => {
|
||||
const HttpRoutersRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/${protocol}/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
{protocol !== 'udp' && (
|
||||
<>
|
||||
<AriaTd>
|
||||
{row.tls && (
|
||||
<Tooltip label="TLS ON">
|
||||
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
||||
<FiShield color="#008000" fill="#008000" size={24} />
|
||||
<Box css={{ width: 20, height: 20 }} data-testid="tls-on">
|
||||
<TlsIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.rule} isTruncated />
|
||||
<TooltipText
|
||||
text={row.rule}
|
||||
css={{
|
||||
display: '-webkit-box',
|
||||
'-webkit-line-clamp': 2,
|
||||
'-webkit-box-orient': 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
maxWidth: '100%',
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
/>
|
||||
</AriaTd>
|
||||
</>
|
||||
)}
|
||||
@@ -52,11 +59,7 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => {
|
||||
<TooltipText text={row.service} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.priority} isTruncated />
|
||||
@@ -86,8 +89,8 @@ export const HttpRoutersRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="TLS" css={{ width: '40px' }} />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="TLS" css={{ width: '24px' }} />
|
||||
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { HttpServiceRender } from './HttpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={new Error('Test error')} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<HttpServicePage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={undefined} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<HttpServicePage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={{} as Resource.DetailsData} error={undefined} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -73,7 +71,7 @@ describe('<HttpServicePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -88,7 +86,7 @@ describe('<HttpServicePage />', () => {
|
||||
expect(serviceDetails.innerHTML).toContain('docker')
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass Host Header')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass host header')
|
||||
expect(serviceDetails.innerHTML).toContain('True')
|
||||
|
||||
const serversList = getByTestId('servers-list')
|
||||
@@ -96,10 +94,9 @@ describe('<HttpServicePage />', () => {
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||
|
||||
expect(() => {
|
||||
getByTestId('health-check')
|
||||
@@ -145,7 +142,7 @@ describe('<HttpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -200,7 +197,7 @@ describe('<HttpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="http" />,
|
||||
{ route: '/http/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -1,323 +1,13 @@
|
||||
import { Badge, Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import {
|
||||
BooleanState,
|
||||
Chips,
|
||||
DetailSection,
|
||||
DetailSectionSkeleton,
|
||||
ItemBlock,
|
||||
ItemTitle,
|
||||
LayoutTwoCols,
|
||||
ProviderName,
|
||||
} from 'components/resources/DetailSections'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { ResourceDetailDataType, ServiceDetailType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type DetailProps = {
|
||||
data: ServiceDetailType
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const ServicesGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const ServersGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const MirrorsGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
|
||||
'> *:not(:first-child)': {
|
||||
justifySelf: 'flex-end',
|
||||
},
|
||||
})
|
||||
|
||||
const GridTitle = styled(Text, {
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: 'hsl(0, 0%, 56%)',
|
||||
})
|
||||
|
||||
type Server = {
|
||||
url: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
[server: string]: string
|
||||
}
|
||||
|
||||
function getServerStatusList(data: ServiceDetailType): ServerStatus {
|
||||
const serversList: ServerStatus = {}
|
||||
|
||||
data.loadBalancer?.servers?.forEach((server: Server) => {
|
||||
serversList[server.address || server.url] = 'DOWN'
|
||||
})
|
||||
|
||||
if (data.serverStatus) {
|
||||
Object.entries(data.serverStatus).forEach(([server, status]) => {
|
||||
serversList[server] = status
|
||||
})
|
||||
}
|
||||
|
||||
return serversList
|
||||
}
|
||||
|
||||
export const ServicePanels = ({ data, protocol = '' }: DetailProps) => {
|
||||
const serversList = getServerStatusList(data)
|
||||
const getProviderFromName = (serviceName: string): string => {
|
||||
const [, provider] = serviceName.split('@')
|
||||
return provider || data.provider
|
||||
}
|
||||
const providerName = useMemo(() => {
|
||||
return data.provider
|
||||
}, [data.provider])
|
||||
|
||||
return (
|
||||
<SpacedColumns css={{ mb: '$5', pb: '$5' }} data-testid="service-details">
|
||||
<DetailSection narrow icon={<FiInfo size={20} />} title="Service Details">
|
||||
<LayoutTwoCols>
|
||||
{data.type && (
|
||||
<ItemBlock title="Type">
|
||||
<Text css={{ lineHeight: '32px' }}>{data.type}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.provider && (
|
||||
<ItemBlock title="Provider">
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{providerName}</ProviderName>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.status && (
|
||||
<ItemBlock title="Status">
|
||||
<ResourceStatus status={data.status} withLabel />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.mirroring && data.mirroring.service && (
|
||||
<ItemBlock title="Main Service">
|
||||
<Badge>{data.mirroring.service}</Badge>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer && (
|
||||
<>
|
||||
{data.loadBalancer.passHostHeader && (
|
||||
<ItemBlock title="Pass Host Header">
|
||||
<BooleanState enabled={data.loadBalancer.passHostHeader} />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.terminationDelay && (
|
||||
<ItemBlock title="Termination Delay">
|
||||
<Text>{`${data.loadBalancer.terminationDelay} ms`}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailSection>
|
||||
{data.loadBalancer?.healthCheck && (
|
||||
<DetailSection narrow icon={<FiShield size={20} />} title="Health Check">
|
||||
<Box data-testid="health-check">
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.scheme && (
|
||||
<ItemBlock title="Scheme">
|
||||
<Text>{data.loadBalancer.healthCheck.scheme}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.interval && (
|
||||
<ItemBlock title="Interval">
|
||||
<Text>{data.loadBalancer.healthCheck.interval}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.path && (
|
||||
<ItemBlock title="Path">
|
||||
<Tooltip label={data.loadBalancer.healthCheck.path} action="copy">
|
||||
<Text>{data.loadBalancer.healthCheck.path}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.timeout && (
|
||||
<ItemBlock title="Timeout">
|
||||
<Text>{data.loadBalancer.healthCheck.timeout}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.port && (
|
||||
<ItemBlock title="Port">
|
||||
<Text>{data.loadBalancer.healthCheck.port}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.hostname && (
|
||||
<ItemBlock title="Hostname">
|
||||
<Tooltip label={data.loadBalancer.healthCheck.hostname} action="copy">
|
||||
<Text>{data.loadBalancer.healthCheck.hostname}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.headers && (
|
||||
<ItemBlock title="Headers">
|
||||
<Chips
|
||||
variant="neon"
|
||||
items={Object.entries(data.loadBalancer.healthCheck.headers).map((entry) => entry.join(': '))}
|
||||
/>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</Box>
|
||||
</DetailSection>
|
||||
)}
|
||||
{!!data?.weighted?.services?.length && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Services" noPadding>
|
||||
<>
|
||||
<ServicesGrid css={{ mt: '$2' }}>
|
||||
<GridTitle>Name</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Weight</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Provider</GridTitle>
|
||||
</ServicesGrid>
|
||||
<Box data-testid="servers-list">
|
||||
{data.weighted.services.map((service) => (
|
||||
<ServicesGrid key={service.name}>
|
||||
<Text>{service.name}</Text>
|
||||
<Text css={{ textAlign: 'center' }}>{service.weight}</Text>
|
||||
<Flex css={{ justifyContent: 'center' }}>
|
||||
<ProviderIcon name={getProviderFromName(service.name)} />
|
||||
</Flex>
|
||||
</ServicesGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
{Object.keys(serversList).length > 0 && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Servers" noPadding>
|
||||
<>
|
||||
<ServersGrid css={{ gridTemplateColumns: protocol === 'http' ? '25% auto' : 'inherit', mt: '$2' }}>
|
||||
{protocol === 'http' && <ItemTitle css={{ mb: 0 }}>Status</ItemTitle>}
|
||||
<ItemTitle css={{ mb: 0 }}>URL</ItemTitle>
|
||||
</ServersGrid>
|
||||
<Box data-testid="servers-list">
|
||||
{Object.entries(serversList).map(([server, status]) => (
|
||||
<ServersGrid key={server} css={{ gridTemplateColumns: protocol === 'http' ? '25% auto' : 'inherit' }}>
|
||||
{protocol === 'http' && <ResourceStatus status={status === 'UP' ? 'enabled' : 'disabled'} />}
|
||||
<Box>
|
||||
<Tooltip label={server} action="copy">
|
||||
<Text>{server}</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</ServersGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
{data.mirroring?.mirrors && data.mirroring.mirrors.length > 0 && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Mirror Services" noPadding>
|
||||
<MirrorsGrid css={{ mt: '$2' }}>
|
||||
<GridTitle>Name</GridTitle>
|
||||
<GridTitle>Percent</GridTitle>
|
||||
<GridTitle>Provider</GridTitle>
|
||||
</MirrorsGrid>
|
||||
<Box data-testid="mirror-services">
|
||||
{data.mirroring.mirrors.map((mirror) => (
|
||||
<MirrorsGrid key={mirror.name}>
|
||||
<Text>{mirror.name}</Text>
|
||||
<Text>{mirror.percent}</Text>
|
||||
<ProviderIcon name={getProviderFromName(mirror.name)} />
|
||||
</MirrorsGrid>
|
||||
))}
|
||||
</Box>
|
||||
</DetailSection>
|
||||
)}
|
||||
</SpacedColumns>
|
||||
)
|
||||
}
|
||||
|
||||
type HttpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
</SpacedColumns>
|
||||
<UsedByRoutersSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<ServicePanels data={data} protocol="http" />
|
||||
<UsedByRoutersSection data={data} protocol="http" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const HttpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services')
|
||||
return <HttpServiceRender data={data} error={error} name={name!} />
|
||||
const { data, error } = useResourceDetail(name ?? '', 'services')
|
||||
|
||||
return <ServiceDetail data={data} error={error} name={name!} protocol="http" />
|
||||
}
|
||||
|
||||
export default HttpService
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
const HttpServicesRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/http/services/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
@@ -67,7 +58,7 @@ export const HttpServicesRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PUBLIC_KEY } from './constants'
|
||||
|
||||
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'
|
||||
|
||||
|
||||
@@ -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,12 +1,10 @@
|
||||
import { TcpMiddlewareRender } from './TcpMiddleware'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpMiddlewarePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={undefined} error={new Error('Test error')} protocol="tcp" />,
|
||||
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={undefined} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={{} as Resource.DetailsData} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -55,7 +53,7 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/middlewares/middleware-simple', withPage: true },
|
||||
)
|
||||
|
||||
@@ -66,14 +64,13 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
||||
expect(middlewareCard.innerHTML).toContain('amount')
|
||||
expect(middlewareCard.innerHTML).toContain('10')
|
||||
expect(container.innerHTML).toContain('inFlightConn')
|
||||
expect(container.innerHTML).toContain('amount')
|
||||
expect(container.innerHTML).toContain('10')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-simple@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test-simple@docker')
|
||||
})
|
||||
|
||||
it('should render a complex middleware', async () => {
|
||||
@@ -106,7 +103,7 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
|
||||
<MiddlewareDetail name="mock-middleware" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/middlewares/middleware-complex', withPage: true },
|
||||
)
|
||||
|
||||
@@ -117,17 +114,16 @@ describe('<TcpMiddlewarePage />', () => {
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('the-provider')
|
||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
||||
expect(middlewareCard.innerHTML).toContain('amount')
|
||||
expect(middlewareCard.innerHTML).toContain('10')
|
||||
expect(middlewareCard.innerHTML).toContain('ipWhiteList')
|
||||
expect(middlewareCard.innerHTML).toContain('source Range')
|
||||
expect(middlewareCard.innerHTML).toContain('125.0.0.1')
|
||||
expect(middlewareCard.innerHTML).toContain('125.0.0.4')
|
||||
expect(container.innerHTML).toContain('inFlightConn')
|
||||
expect(container.innerHTML).toContain('amount')
|
||||
expect(container.innerHTML).toContain('10')
|
||||
expect(container.innerHTML).toContain('ipWhiteList')
|
||||
expect(container.innerHTML).toContain('source Range')
|
||||
expect(container.innerHTML).toContain('125.0.0.1')
|
||||
expect(container.innerHTML).toContain('125.0.0.4')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-complex@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test-complex@docker')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,82 +1,12 @@
|
||||
import { Card, Box, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import { RenderMiddleware } from 'components/resources/MiddlewarePanel'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
import breakpoints from 'utils/breakpoints'
|
||||
|
||||
const MiddlewareGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
|
||||
|
||||
[`@media (max-width: ${breakpoints.tablet})`]: {
|
||||
gridTemplateColumns: '1fr',
|
||||
},
|
||||
})
|
||||
|
||||
type TcpMiddlewareRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$4' }} data-testid="skeleton" />
|
||||
<MiddlewareGrid>
|
||||
<DetailSectionSkeleton />
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<MiddlewareGrid>
|
||||
<Card css={{ padding: '$5' }} data-testid="middleware-card">
|
||||
<RenderMiddleware middleware={data} />
|
||||
</Card>
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="tcp" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const TcpMiddleware = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'middlewares', 'tcp')
|
||||
return <TcpMiddlewareRender data={data} error={error} name={name!} />
|
||||
return <MiddlewareDetail data={data} error={error} name={name!} protocol="tcp" />
|
||||
}
|
||||
|
||||
export default TcpMiddleware
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
return (
|
||||
<ClickableRow key={row.name} to={`/tcp/middlewares/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
@@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<TooltipText text={middlewareType} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
@@ -69,7 +60,7 @@ export const TcpMiddlewaresRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { TcpRouterRender } from './TcpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={new Error('Test error')} protocol="tcp" />,
|
||||
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<TcpRouterPage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<TcpRouterPage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={{} as Resource.DetailsData} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -68,38 +66,24 @@ describe('<TcpRouterPage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/routers/tcp-all@docker', withPage: true },
|
||||
)
|
||||
|
||||
const routerStructure = getByTestId('router-structure')
|
||||
expect(routerStructure.innerHTML).toContain(':443')
|
||||
expect(routerStructure.innerHTML).toContain(':8000')
|
||||
expect(routerStructure.innerHTML).toContain('tcp-all@docker')
|
||||
expect(routerStructure.innerHTML).toContain('tcp-all</span>')
|
||||
expect(routerStructure.innerHTML).toContain('TCP Router')
|
||||
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
||||
|
||||
const routerDetailsSection = getByTestId('router-details')
|
||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
||||
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Status')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Success')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Provider')
|
||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Name')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('tcp-all@docker')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web</')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web-secured')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('tcp-all</')
|
||||
|
||||
const middlewaresPanel = routerDetailsSection.querySelector(':scope > div:nth-child(3)')
|
||||
const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || [])
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware00')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware01')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('Success')
|
||||
expect(providers.length).toBe(2)
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Status')
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Success')
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Provider')
|
||||
expect(routerDetailsSection?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||
|
||||
expect(getByTestId('/tcp/services/tcp-all@docker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,91 +1,13 @@
|
||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import MiddlewarePanel from 'components/resources/MiddlewarePanel'
|
||||
import RouterPanel from 'components/resources/RouterPanel'
|
||||
import TlsPanel from 'components/resources/TlsPanel'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { RouterStructure } from 'pages/http/HttpRouter'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type DetailProps = {
|
||||
data: ResourceDetailDataType
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const RouterDetail = ({ data }: DetailProps) => (
|
||||
<SpacedColumns data-testid="router-details">
|
||||
<RouterPanel data={data} />
|
||||
<TlsPanel data={data} />
|
||||
<MiddlewarePanel data={data} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type TcpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
|
||||
<CardListSection bigDescription />
|
||||
<CardListSection />
|
||||
<CardListSection isLast />
|
||||
</Flex>
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
</SpacedColumns>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<RouterStructure data={data} protocol="tcp" />
|
||||
<RouterDetail data={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const TcpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers', 'tcp')
|
||||
return <TcpRouterRender data={data} error={error} name={name!} />
|
||||
|
||||
return <RouterDetail data={data} error={error} name={name!} protocol="tcp" />
|
||||
}
|
||||
|
||||
export default TcpRouter
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailSections'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailItemComponents'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import TlsIcon from 'components/routers/TlsIcon'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
@@ -22,17 +22,13 @@ export const makeRowRender = (): RenderRowType => {
|
||||
const TcpRoutersRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/tcp/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
{row.tls && (
|
||||
<Tooltip label="TLS ON">
|
||||
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
||||
<FiShield color="#008000" fill="#008000" size={24} />
|
||||
<TlsIcon />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -48,11 +44,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<TooltipText text={row.service} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.priority} isTruncated />
|
||||
@@ -82,7 +74,7 @@ export const TcpRoutersRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="TLS" css={{ width: '40px' }} />
|
||||
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { TcpServiceRender } from './TcpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={new Error('Test error')} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={{} as Resource.DetailsData} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -71,7 +69,7 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -79,38 +77,37 @@ describe('<TcpServicePage />', () => {
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'service-test1')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const serviceDetails = getByTestId('tcp-service-details')
|
||||
const serviceDetails = getByTestId('service-details')
|
||||
expect(serviceDetails.innerHTML).toContain('Type')
|
||||
expect(serviceDetails.innerHTML).toContain('loadbalancer')
|
||||
expect(serviceDetails.innerHTML).toContain('Provider')
|
||||
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
expect(serviceDetails.innerHTML).toContain('Termination Delay')
|
||||
expect(serviceDetails.innerHTML).toContain('Termination delay')
|
||||
expect(serviceDetails.innerHTML).toContain('10 ms')
|
||||
|
||||
const healthCheck = getByTestId('tcp-health-check')
|
||||
const healthCheck = getByTestId('health-check')
|
||||
expect(healthCheck.innerHTML).toContain('Interval')
|
||||
expect(healthCheck.innerHTML).toContain('30s')
|
||||
expect(healthCheck.innerHTML).toContain('Timeout')
|
||||
expect(healthCheck.innerHTML).toContain('10s')
|
||||
expect(healthCheck.innerHTML).toContain('Port')
|
||||
expect(healthCheck.innerHTML).toContain('8080')
|
||||
expect(healthCheck.innerHTML).toContain('Unhealthy Interval')
|
||||
expect(healthCheck.innerHTML).toContain('Unhealthy interval')
|
||||
expect(healthCheck.innerHTML).toContain('1m')
|
||||
expect(healthCheck.innerHTML).toContain('Send')
|
||||
expect(healthCheck.innerHTML).toContain('PING')
|
||||
expect(healthCheck.innerHTML).toContain('Expect')
|
||||
expect(healthCheck.innerHTML).toContain('PONG')
|
||||
|
||||
const serversList = getByTestId('tcp-servers-list')
|
||||
const serversList = getByTestId('servers-list')
|
||||
expect(serversList.childNodes.length).toBe(1)
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||
})
|
||||
|
||||
it('should render the service servers from the serverStatus property', async () => {
|
||||
@@ -153,19 +150,18 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
const serversList = getByTestId('tcp-servers-list')
|
||||
const serversList = getByTestId('servers-list')
|
||||
expect(serversList.childNodes.length).toBe(1)
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||
})
|
||||
|
||||
it('should not render used by routers table if the usedBy property is empty', async () => {
|
||||
@@ -180,7 +176,7 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
{ route: '/tcp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -223,14 +219,14 @@ describe('<TcpServicePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="tcp" />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'weighted-service-test')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const serviceDetails = getByTestId('tcp-service-details')
|
||||
const serviceDetails = getByTestId('service-details')
|
||||
expect(serviceDetails.innerHTML).toContain('Type')
|
||||
expect(serviceDetails.innerHTML).toContain('weighted')
|
||||
expect(serviceDetails.innerHTML).toContain('Provider')
|
||||
@@ -238,7 +234,7 @@ describe('<TcpServicePage />', () => {
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
|
||||
const weightedServices = getByTestId('tcp-weighted-services')
|
||||
const weightedServices = getByTestId('weighted-services')
|
||||
expect(weightedServices.childNodes.length).toBe(2)
|
||||
expect(weightedServices.innerHTML).toContain('service1@docker')
|
||||
expect(weightedServices.innerHTML).toContain('80')
|
||||
|
||||
@@ -1,291 +1,13 @@
|
||||
import { Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import {
|
||||
DetailSection,
|
||||
DetailSectionSkeleton,
|
||||
ItemBlock,
|
||||
ItemTitle,
|
||||
LayoutTwoCols,
|
||||
ProviderName,
|
||||
} from 'components/resources/DetailSections'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { ResourceDetailDataType, ServiceDetailType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type TcpDetailProps = {
|
||||
data: ServiceDetailType
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const ServicesGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const ServersGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const GridTitle = styled(Text, {
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: 'hsl(0, 0%, 56%)',
|
||||
})
|
||||
|
||||
type TcpServer = {
|
||||
address: string
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
[server: string]: string
|
||||
}
|
||||
|
||||
type TcpHealthCheck = {
|
||||
port?: number
|
||||
send?: string
|
||||
expect?: string
|
||||
interval?: string
|
||||
unhealthyInterval?: string
|
||||
timeout?: string
|
||||
}
|
||||
|
||||
function getTcpServerStatusList(data: ServiceDetailType): ServerStatus {
|
||||
const serversList: ServerStatus = {}
|
||||
|
||||
data.loadBalancer?.servers?.forEach((server: any) => {
|
||||
// TCP servers should have address, but handle both url and address for compatibility
|
||||
const serverKey = (server as TcpServer).address || (server as any).url
|
||||
if (serverKey) {
|
||||
serversList[serverKey] = 'DOWN'
|
||||
}
|
||||
})
|
||||
|
||||
if (data.serverStatus) {
|
||||
Object.entries(data.serverStatus).forEach(([server, status]) => {
|
||||
serversList[server] = status
|
||||
})
|
||||
}
|
||||
|
||||
return serversList
|
||||
}
|
||||
|
||||
export const TcpServicePanels = ({ data }: TcpDetailProps) => {
|
||||
const serversList = getTcpServerStatusList(data)
|
||||
const getProviderFromName = (serviceName: string): string => {
|
||||
const [, provider] = serviceName.split('@')
|
||||
return provider || data.provider
|
||||
}
|
||||
const providerName = useMemo(() => {
|
||||
return data.provider
|
||||
}, [data.provider])
|
||||
|
||||
return (
|
||||
<SpacedColumns css={{ mb: '$5', pb: '$5' }} data-testid="tcp-service-details">
|
||||
<DetailSection narrow icon={<FiInfo size={20} />} title="Service Details">
|
||||
<LayoutTwoCols>
|
||||
{data.type && (
|
||||
<ItemBlock title="Type">
|
||||
<Text css={{ lineHeight: '32px' }}>{data.type}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.provider && (
|
||||
<ItemBlock title="Provider">
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{providerName}</ProviderName>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.status && (
|
||||
<ItemBlock title="Status">
|
||||
<ResourceStatus status={data.status} withLabel />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer && (
|
||||
<>
|
||||
{data.loadBalancer.terminationDelay && (
|
||||
<ItemBlock title="Termination Delay">
|
||||
<Text>{`${data.loadBalancer.terminationDelay} ms`}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailSection>
|
||||
{data.loadBalancer?.healthCheck && (
|
||||
<DetailSection narrow icon={<FiShield size={20} />} title="Health Check">
|
||||
<Box data-testid="tcp-health-check">
|
||||
{(() => {
|
||||
const tcpHealthCheck = data.loadBalancer.healthCheck as unknown as TcpHealthCheck
|
||||
return (
|
||||
<>
|
||||
<LayoutTwoCols>
|
||||
{tcpHealthCheck.interval && (
|
||||
<ItemBlock title="Interval">
|
||||
<Text>{tcpHealthCheck.interval}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{tcpHealthCheck.timeout && (
|
||||
<ItemBlock title="Timeout">
|
||||
<Text>{tcpHealthCheck.timeout}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{tcpHealthCheck.port && (
|
||||
<ItemBlock title="Port">
|
||||
<Text>{tcpHealthCheck.port}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{tcpHealthCheck.unhealthyInterval && (
|
||||
<ItemBlock title="Unhealthy Interval">
|
||||
<Text>{tcpHealthCheck.unhealthyInterval}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{tcpHealthCheck.send && (
|
||||
<ItemBlock title="Send">
|
||||
<Tooltip label={tcpHealthCheck.send} action="copy">
|
||||
<Text>{tcpHealthCheck.send}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{tcpHealthCheck.expect && (
|
||||
<ItemBlock title="Expect">
|
||||
<Tooltip label={tcpHealthCheck.expect} action="copy">
|
||||
<Text>{tcpHealthCheck.expect}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</DetailSection>
|
||||
)}
|
||||
{!!data?.weighted?.services?.length && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Services" noPadding>
|
||||
<>
|
||||
<ServicesGrid css={{ mt: '$2' }}>
|
||||
<GridTitle>Name</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Weight</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Provider</GridTitle>
|
||||
</ServicesGrid>
|
||||
<Box data-testid="tcp-weighted-services">
|
||||
{data.weighted.services.map((service) => (
|
||||
<ServicesGrid key={service.name}>
|
||||
<Text>{service.name}</Text>
|
||||
<Text css={{ textAlign: 'center' }}>{service.weight}</Text>
|
||||
<Flex css={{ justifyContent: 'center' }}>
|
||||
<ProviderIcon name={getProviderFromName(service.name)} />
|
||||
</Flex>
|
||||
</ServicesGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
{Object.keys(serversList).length > 0 && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Servers" noPadding>
|
||||
<>
|
||||
<ServersGrid css={{ gridTemplateColumns: '25% auto', mt: '$2' }}>
|
||||
<ItemTitle css={{ mb: 0 }}>Status</ItemTitle>
|
||||
<ItemTitle css={{ mb: 0 }}>Address</ItemTitle>
|
||||
</ServersGrid>
|
||||
<Box data-testid="tcp-servers-list">
|
||||
{Object.entries(serversList).map(([server, status]) => (
|
||||
<ServersGrid key={server} css={{ gridTemplateColumns: '25% auto' }}>
|
||||
<ResourceStatus status={status === 'UP' ? 'enabled' : 'disabled'} />
|
||||
<Box>
|
||||
<Tooltip label={server} action="copy">
|
||||
<Text>{server}</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</ServersGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
</SpacedColumns>
|
||||
)
|
||||
}
|
||||
|
||||
type TcpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
</SpacedColumns>
|
||||
<UsedByRoutersSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<TcpServicePanels data={data} />
|
||||
<UsedByRoutersSection data={data} protocol="tcp" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const TcpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services', 'tcp')
|
||||
return <TcpServiceRender data={data} error={error} name={name!} />
|
||||
|
||||
return <ServiceDetail data={data} error={error} name={name!} protocol="tcp" />
|
||||
}
|
||||
|
||||
export default TcpService
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
const TcpServicesRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/tcp/services/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
@@ -67,7 +58,7 @@ export const TcpServicesRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { UdpRouterRender } from './UdpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={new Error('Test error')} protocol="udp" />,
|
||||
{ route: '/udp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<UdpRouterPage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={undefined} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<UdpRouterPage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={{} as Resource.DetailsData} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/routers/mock-router', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -53,31 +51,22 @@ describe('<UdpRouterPage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<UdpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
|
||||
<RouterDetail name="mock-router" data={mockData as any} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/routers/udp-all@docker', withPage: true },
|
||||
)
|
||||
|
||||
const routerStructure = getByTestId('router-structure')
|
||||
expect(routerStructure.innerHTML).toContain(':443')
|
||||
expect(routerStructure.innerHTML).toContain(':8000')
|
||||
expect(routerStructure.innerHTML).toContain('udp-all@docker')
|
||||
expect(routerStructure.innerHTML).toContain('udp-all</span>')
|
||||
expect(routerStructure.innerHTML).toContain('UDP Router')
|
||||
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
||||
|
||||
const routerDetailsSection = getByTestId('router-details')
|
||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
||||
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Status')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Success')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Provider')
|
||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Name')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('udp-all@docker')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web</')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web-secured')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('udp-all</')
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Status')
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Success')
|
||||
expect(routerDetailsSection?.innerHTML).toContain('Provider')
|
||||
expect(routerDetailsSection?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(getByTestId('/udp/services/udp-all@docker')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,88 +1,13 @@
|
||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import RouterPanel from 'components/resources/RouterPanel'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { RouterStructure } from 'pages/http/HttpRouter'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type DetailProps = {
|
||||
data: ResourceDetailDataType
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const RouterDetail = ({ data }: DetailProps) => (
|
||||
<SpacedColumns data-testid="router-details">
|
||||
<RouterPanel data={data} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type UdpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
|
||||
<CardListSection bigDescription />
|
||||
<CardListSection />
|
||||
<CardListSection isLast />
|
||||
</Flex>
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
</SpacedColumns>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<RouterStructure data={data} protocol="udp" />
|
||||
<RouterDetail data={data} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const UdpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers', 'udp')
|
||||
|
||||
return <UdpRouterRender data={data} error={error} name={name!} />
|
||||
return <RouterDetail data={data} error={error} name={name!} protocol="udp" />
|
||||
}
|
||||
|
||||
export default UdpRouter
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailSections'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailItemComponents'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -21,11 +20,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
const UdpRoutersRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/udp/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>{row.entryPoints && row.entryPoints.length > 0 && <Chips items={row.entryPoints} />}</AriaTd>
|
||||
<AriaTd>
|
||||
@@ -35,11 +30,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<TooltipText text={row.service} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.priority} isTruncated />
|
||||
@@ -69,7 +60,7 @@ export const UdpRoutersRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Service" isSortable sortByValue="service" />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { UdpServiceRender } from './UdpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={new Error('Test error')} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
@@ -14,7 +12,7 @@ describe('<UdpServicePage />', () => {
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={undefined} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
@@ -22,7 +20,7 @@ describe('<UdpServicePage />', () => {
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={{} as Resource.DetailsData} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
@@ -61,7 +59,7 @@ describe('<UdpServicePage />', () => {
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -76,9 +74,9 @@ describe('<UdpServicePage />', () => {
|
||||
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass Host Header')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass host header')
|
||||
expect(serviceDetails.innerHTML).toContain('True')
|
||||
expect(serviceDetails.innerHTML).toContain('Termination Delay')
|
||||
expect(serviceDetails.innerHTML).toContain('Termination delay')
|
||||
expect(serviceDetails.innerHTML).toContain('10 ms')
|
||||
|
||||
const serversList = getByTestId('servers-list')
|
||||
@@ -86,9 +84,8 @@ describe('<UdpServicePage />', () => {
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||
})
|
||||
|
||||
it('should render the service servers from the serverStatus property', async () => {
|
||||
@@ -131,7 +128,7 @@ describe('<UdpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
@@ -140,10 +137,9 @@ describe('<UdpServicePage />', () => {
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
||||
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||
})
|
||||
|
||||
it('should not render used by routers table if the usedBy property is empty', async () => {
|
||||
@@ -158,7 +154,7 @@ describe('<UdpServicePage />', () => {
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
<ServiceDetail name="mock-service" data={mockData as any} error={undefined} protocol="udp" />,
|
||||
{ route: '/udp/services/mock-service', withPage: true },
|
||||
)
|
||||
|
||||
|
||||
@@ -1,75 +1,13 @@
|
||||
import { Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import { ServicePanels } from 'pages/http/HttpService'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
type UdpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
</SpacedColumns>
|
||||
<UsedByRoutersSkeleton />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{data.name} - Traefik Proxy</title>
|
||||
</Helmet>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<ServicePanels data={data} />
|
||||
<UsedByRoutersSection data={data} protocol="udp" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||
|
||||
export const UdpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services', 'udp')
|
||||
return <UdpServiceRender data={data} error={error} name={name!} />
|
||||
|
||||
return <ServiceDetail data={data} error={error} name={name!} protocol="udp" />
|
||||
}
|
||||
|
||||
export default UdpService
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import ClickableRow from 'components/tables/ClickableRow'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
import Tooltip from 'components/Tooltip'
|
||||
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||
import TooltipText from 'components/TooltipText'
|
||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
const UdpServicesRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/udp/services/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<ProviderIconWithTooltip provider={row.provider} />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
@@ -67,7 +58,7 @@ export const UdpServicesRender = ({
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Status" css={{ width: '36px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||
|
||||
9
webui/src/types/object.d.ts
vendored
Normal file
9
webui/src/types/object.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare namespace Object {
|
||||
type JSONObject = {
|
||||
[x: string]: string | number
|
||||
}
|
||||
|
||||
type ValuesMapType = {
|
||||
[key: string]: string | number | JSONObject
|
||||
}
|
||||
}
|
||||
123
webui/src/types/resources.d.ts
vendored
Normal file
123
webui/src/types/resources.d.ts
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
declare namespace Resource {
|
||||
type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading'
|
||||
|
||||
type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData
|
||||
}
|
||||
|
||||
declare namespace Entrypoint {
|
||||
type Details = {
|
||||
name: string
|
||||
address: string
|
||||
message?: string
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace Router {
|
||||
type TlsDomain = {
|
||||
main: string
|
||||
sans: string[]
|
||||
}
|
||||
|
||||
type TLS = {
|
||||
options: string
|
||||
certResolver: string
|
||||
domains: TlsDomain[]
|
||||
passthrough: boolean
|
||||
}
|
||||
|
||||
type Details = {
|
||||
name: string
|
||||
service?: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
rule?: string
|
||||
priority?: number
|
||||
provider: string
|
||||
tls?: {
|
||||
options: string
|
||||
certResolver: string
|
||||
domains: TlsDomain[]
|
||||
passthrough: boolean
|
||||
}
|
||||
error?: string[]
|
||||
entryPoints?: string[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
type DetailsData = Details & {
|
||||
middlewares?: Middleware.Details[]
|
||||
hasValidMiddlewares?: boolean
|
||||
entryPointsData?: Entrypoint.Details[]
|
||||
using?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace Service {
|
||||
type WeightedService = {
|
||||
name: string
|
||||
weight: number
|
||||
}
|
||||
|
||||
type Mirror = {
|
||||
name: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
type Details = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type: string
|
||||
usedBy?: string[]
|
||||
routers?: Router[]
|
||||
serverStatus?: {
|
||||
[server: string]: string
|
||||
}
|
||||
mirroring?: {
|
||||
service: string
|
||||
mirrors?: Mirror[]
|
||||
}
|
||||
loadBalancer?: {
|
||||
servers?: { url: string }[]
|
||||
passHostHeader?: boolean
|
||||
terminationDelay?: number
|
||||
healthCheck?: {
|
||||
scheme: string
|
||||
path: string
|
||||
hostname: string
|
||||
headers?: {
|
||||
[header: string]: string
|
||||
}
|
||||
port?: number
|
||||
send?: string
|
||||
expect?: string
|
||||
interval?: string
|
||||
unhealthyInterval?: string
|
||||
timeout?: string
|
||||
}
|
||||
}
|
||||
weighted?: {
|
||||
services?: WeightedService[]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace Middleware {
|
||||
type Props = {
|
||||
[prop: string]: ValuesMapType
|
||||
}
|
||||
|
||||
type Details = {
|
||||
name: string
|
||||
status: 'enabled' | 'disabled' | 'warning'
|
||||
provider: string
|
||||
type?: string
|
||||
plugin?: Record<string, unknown>
|
||||
error?: string[]
|
||||
routers?: string[]
|
||||
usedBy?: string[]
|
||||
} & Props
|
||||
|
||||
type DetailsData = Details & {
|
||||
routers?: Router.Details[]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user