1
0
mirror of https://github.com/containous/traefik.git synced 2025-12-18 20:23:55 +03:00

Compare commits

...

5 Commits

Author SHA1 Message Date
Nándor Kollár
704f69272c Support Nginx upstream-vhost annotation 2025-12-17 16:42:04 +01:00
Gina A.
4854dee208 Details pages UI improvement 2025-12-16 16:30:05 +01:00
mmatur
34b91218f4 Merge v3.6 into master 2025-12-01 16:28:00 +01:00
Gina A.
8bdcd72042 Web UI dashboard improvements 2025-11-21 09:00:05 +01:00
kevinpollet
2ad42cd0ec Merge branch v3.6 into master 2025-11-07 16:47:21 +01:00
89 changed files with 2880 additions and 2850 deletions

View File

@@ -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> | |

View File

@@ -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.

View File

@@ -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

View File

@@ -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 {

View File

@@ -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",

View File

@@ -101,5 +101,5 @@
"public"
]
},
"packageManager": "yarn@4.9.1"
"packageManager": "yarn@4.12.0"
}

View 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>
)
}

View 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

View File

@@ -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' }}>

View 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

View File

@@ -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>
)
}

View 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

View 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>
</>
)
}

View File

@@ -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

View File

@@ -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')
})
})

View File

@@ -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

View 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',
})

View File

@@ -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' }}>&nbsp;</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',
})

View 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>
)
}

View File

@@ -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>
))}

View File

@@ -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

View 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

View File

@@ -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>
)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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

View File

@@ -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>
)
}

View 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>
</>
)
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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>
</>
)
}

View 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

View 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

View File

@@ -0,0 +1,4 @@
export const getProviderFromName = (serviceName: string, defaultProvider: string): string => {
const [, provider] = serviceName.split('@')
return provider || defaultProvider
}

View 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>
)
}

View File

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

View File

@@ -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

View File

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

View File

@@ -0,0 +1,119 @@
import qs from 'query-string'
import { useMemo } from 'react'
import { useHref, useLocation, useSearchParams } from 'react-router-dom'
import { capitalizeFirstLetter } from '../utils/string'
type UseGetUrlWithReturnTo = (href: string, initialReturnTo?: string) => string
export const useGetUrlWithReturnTo: UseGetUrlWithReturnTo = (href, initialReturnTo) => {
const location = useLocation()
const currentPath = location.pathname + location.search
const url = useMemo(() => {
if (href) {
return qs.stringifyUrl({ url: href, query: { returnTo: initialReturnTo ?? currentPath } })
}
return href
}, [currentPath, href, initialReturnTo])
return url
}
export const useHrefWithReturnTo = (href: string, returnTo?: string): string => {
const urlWithReturnTo = useGetUrlWithReturnTo(href, returnTo)
return useHref(urlWithReturnTo)
}
const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record<string, Record<string, string>> = {
http: {
routers: 'HTTP router',
services: 'HTTP service',
middlewares: 'HTTP middleware',
},
tcp: {
routers: 'TCP router',
services: 'TCP service',
middlewares: 'TCP middleware',
},
udp: {
routers: 'UDP router',
services: '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],
)
}

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
</>
)
}

View File

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

View File

@@ -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) : []

View File

@@ -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)

View File

@@ -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",

View File

@@ -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,

View File

@@ -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"
}
]

View File

@@ -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"]
}
]

View File

@@ -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"
}

View File

@@ -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 &gt; child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; sibling &gt; negative Grand Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; sibling &gt; positive Grand Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; string Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; array Child')
expect(container.innerHTML).toContain('jwtAuth &gt; child')
expect(container.innerHTML).toContain('jwtAuth &gt; sibling &gt; negative Grand Child')
expect(container.innerHTML).toContain('jwtAuth &gt; sibling &gt; positive Grand Child')
expect(container.innerHTML).toContain('jwtAuth &gt; string Child')
expect(container.innerHTML).toContain('jwtAuth &gt; 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)
})
})

View File

@@ -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

View File

@@ -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" />

View File

@@ -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()

View File

@@ -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

View File

@@ -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" />

View File

@@ -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 },
)

View File

@@ -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

View File

@@ -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" />

View File

@@ -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'

View File

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

View File

@@ -1,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')
})
})

View File

@@ -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

View File

@@ -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" />

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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" />

View File

@@ -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')

View File

@@ -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

View File

@@ -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" />

View File

@@ -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()
})

View File

@@ -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

View File

@@ -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" />

View File

@@ -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 },
)

View File

@@ -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

View File

@@ -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
View 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
View 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[]
}
}