mirror of
https://github.com/traefik/traefik
synced 2026-02-03 11:10:33 +00:00
Details pages UI improvement
This commit is contained in:
+1
-1
@@ -101,5 +101,5 @@
|
|||||||
"public"
|
"public"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.9.1"
|
"packageManager": "yarn@4.12.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Card, styled } from '@traefiklabs/faency'
|
||||||
|
|
||||||
|
const ScrollableCard = styled(Card, {
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
scrollbarWidth: 'thin',
|
||||||
|
scrollbarColor: '$colors-primary $colors-01dp',
|
||||||
|
scrollbarGutter: 'stable',
|
||||||
|
})
|
||||||
|
|
||||||
|
export default ScrollableCard
|
||||||
@@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'
|
|||||||
import { ReactNode, useEffect } from 'react'
|
import { ReactNode, useEffect } from 'react'
|
||||||
import { FiX } from 'react-icons/fi'
|
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, {
|
const CloseButton = styled(Button, {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -39,7 +39,7 @@ const toastVariants = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ToastState = {
|
export type ToastState = {
|
||||||
severity: StatusType
|
severity: Resource.Status
|
||||||
message?: string
|
message?: string
|
||||||
isVisible?: boolean
|
isVisible?: boolean
|
||||||
key?: string
|
key?: string
|
||||||
@@ -88,7 +88,7 @@ export const Toast = ({ message, dismiss, severity = 'error', icon, isVisible =
|
|||||||
exit="hidden"
|
exit="hidden"
|
||||||
variants={toastVariants}
|
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>
|
<Text css={{ color: 'white', fontWeight: 600, lineHeight: '$4' }}>{message}</Text>
|
||||||
{!timeout && (
|
{!timeout && (
|
||||||
<CloseButton ghost onClick={dismiss} css={{ px: '$2' }}>
|
<CloseButton ghost onClick={dismiss} css={{ px: '$2' }}>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { Flex, Button, CSS, AccessibleIcon } from '@traefiklabs/faency'
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { FiCheck, FiCopy } from 'react-icons/fi'
|
||||||
|
|
||||||
|
type CopyButtonProps = {
|
||||||
|
text: string
|
||||||
|
disabled?: boolean
|
||||||
|
css?: CSS
|
||||||
|
onClick?: () => void
|
||||||
|
iconOnly?: boolean
|
||||||
|
title?: string
|
||||||
|
color?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const CopyButton = ({
|
||||||
|
text,
|
||||||
|
disabled,
|
||||||
|
css,
|
||||||
|
onClick,
|
||||||
|
iconOnly = false,
|
||||||
|
title = 'Copy',
|
||||||
|
color = 'var(--colors-textSubtle)',
|
||||||
|
}: CopyButtonProps) => {
|
||||||
|
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ghost
|
||||||
|
size="small"
|
||||||
|
css={{
|
||||||
|
color: '$hiContrast',
|
||||||
|
px: iconOnly ? '$1' : undefined,
|
||||||
|
...css,
|
||||||
|
}}
|
||||||
|
title={title}
|
||||||
|
onClick={async (e: React.MouseEvent): Promise<void> => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
if (onClick) onClick()
|
||||||
|
setShowConfirmation(true)
|
||||||
|
setTimeout(() => setShowConfirmation(false), 1500)
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={2} css={{ userSelect: 'none' }}>
|
||||||
|
<AccessibleIcon label="copy">
|
||||||
|
{showConfirmation ? <FiCheck color={color} size={14} /> : <FiCopy color={color} size={14} />}
|
||||||
|
</AccessibleIcon>
|
||||||
|
{!iconOnly ? (showConfirmation ? 'Copied!' : title) : null}
|
||||||
|
</Flex>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CopyButton
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Box } from '@traefiklabs/faency'
|
||||||
import { HTMLAttributes, useMemo } from 'react'
|
import { HTMLAttributes, useMemo } from 'react'
|
||||||
|
|
||||||
import Consul from 'components/icons/providers/Consul'
|
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 Plugin from 'components/icons/providers/Plugin'
|
||||||
import Redis from 'components/icons/providers/Redis'
|
import Redis from 'components/icons/providers/Redis'
|
||||||
import Zookeeper from 'components/icons/providers/Zookeeper'
|
import Zookeeper from 'components/icons/providers/Zookeeper'
|
||||||
|
import Tooltip from 'components/Tooltip'
|
||||||
|
|
||||||
export type ProviderIconProps = HTMLAttributes<SVGElement> & {
|
export type ProviderIconProps = HTMLAttributes<SVGElement> & {
|
||||||
height?: number | string
|
height?: number | string
|
||||||
width?: 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(() => {
|
const Icon = useMemo(() => {
|
||||||
if (!name || typeof name !== 'string') return Internal
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+9
-15
@@ -1,11 +1,9 @@
|
|||||||
import { Text } from '@traefiklabs/faency'
|
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
import { BooleanState, ItemBlock } from './DetailSections'
|
import CopyableText from 'components/CopyableText'
|
||||||
import GenericTable from './GenericTable'
|
import { BooleanState, ItemBlock } from 'components/resources/DetailItemComponents'
|
||||||
import IpStrategyTable, { IpStrategy } from './IpStrategyTable'
|
import GenericTable from 'components/resources/GenericTable'
|
||||||
|
import IpStrategyTable, { IpStrategy } from 'components/resources/IpStrategyTable'
|
||||||
import Tooltip from 'components/Tooltip'
|
|
||||||
|
|
||||||
type RenderUnknownPropProps = {
|
type RenderUnknownPropProps = {
|
||||||
name: string
|
name: string
|
||||||
@@ -22,23 +20,19 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno
|
|||||||
try {
|
try {
|
||||||
if (typeof prop !== 'undefined') {
|
if (typeof prop !== 'undefined') {
|
||||||
if (typeof prop === 'boolean') {
|
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())) {
|
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)) {
|
if (['string', 'number'].includes(typeof prop)) {
|
||||||
return wrap(
|
return wrap(<CopyableText text={prop as string} css={{ fontSize: '$3' }} />)
|
||||||
<Tooltip label={prop as string} action="copy">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{prop as string}</Text>
|
|
||||||
</Tooltip>,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (JSON.stringify(prop) === '{}') {
|
if (JSON.stringify(prop) === '{}') {
|
||||||
return wrap(<BooleanState enabled />)
|
return wrap(<BooleanState enabled css={{ fontSize: '$3' }} />)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prop instanceof Array) {
|
if (prop instanceof Array) {
|
||||||
@@ -75,7 +69,7 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('Unable to render plugin property:', { name, prop }, { error })
|
console.error('Unable to render plugin property:', { name, prop }, { error })
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import AdditionalFeatures from './AdditionalFeatures'
|
|
||||||
|
|
||||||
import { MiddlewareProps } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
|
||||||
|
|
||||||
describe('<AdditionalFeatures />', () => {
|
|
||||||
it('should render the middleware info', () => {
|
|
||||||
renderWithProviders(<AdditionalFeatures uid="test-key" />)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render the middleware info with number', () => {
|
|
||||||
const middlewares: MiddlewareProps[] = [
|
|
||||||
{
|
|
||||||
retry: {
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
|
||||||
|
|
||||||
expect(container.innerHTML).toContain('Retry: Attempts=2')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render the middleware info with string', () => {
|
|
||||||
const middlewares: MiddlewareProps[] = [
|
|
||||||
{
|
|
||||||
circuitBreaker: {
|
|
||||||
expression: 'expression',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
|
||||||
|
|
||||||
expect(container.innerHTML).toContain('CircuitBreaker: Expression="expression"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should render the middleware info with string', () => {
|
|
||||||
const middlewares: MiddlewareProps[] = [
|
|
||||||
{
|
|
||||||
rateLimit: {
|
|
||||||
burst: 100,
|
|
||||||
average: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
|
|
||||||
|
|
||||||
expect(container.innerHTML).toContain('RateLimit: Burst=100, Average=100')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { Badge, Box, Text } from '@traefiklabs/faency'
|
|
||||||
|
|
||||||
import Tooltip from 'components/Tooltip'
|
|
||||||
import { MiddlewareProps, ValuesMapType } from 'hooks/use-resource-detail'
|
|
||||||
|
|
||||||
function capitalize(word: string): string {
|
|
||||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function quote(value: string | number): string | number {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return `"${value}"`
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
function quoteArray(values: (string | number)[]): (string | number)[] {
|
|
||||||
return values.map(quote)
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderFeatureValues = (valuesMap: ValuesMapType): string => {
|
|
||||||
return Object.entries(valuesMap)
|
|
||||||
.map(([name, value]) => {
|
|
||||||
const capitalizedName = capitalize(name)
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return [capitalizedName, `"${value}"`].join('=')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value instanceof Array) {
|
|
||||||
return [capitalizedName, quoteArray(value).join(', ')].join('=')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return [capitalizedName, `{${renderFeatureValues(value)}}`].join('=')
|
|
||||||
}
|
|
||||||
|
|
||||||
return [capitalizedName, value].join('=')
|
|
||||||
})
|
|
||||||
.join(', ')
|
|
||||||
}
|
|
||||||
|
|
||||||
const FeatureMiddleware = ({ middleware }: { middleware: MiddlewareProps }) => {
|
|
||||||
const [name, value] = Object.entries(middleware)[0]
|
|
||||||
const content = `${capitalize(name)}: ${renderFeatureValues(value)}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip label={content} action="copy">
|
|
||||||
<Badge variant="blue" css={{ mr: '$2', mt: '$2' }}>
|
|
||||||
{content}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type AdditionalFeaturesProps = {
|
|
||||||
middlewares?: MiddlewareProps[]
|
|
||||||
uid: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const AdditionalFeatures = ({ middlewares, uid }: AdditionalFeaturesProps) => {
|
|
||||||
return middlewares?.length ? (
|
|
||||||
<Box css={{ mt: '-$2' }}>
|
|
||||||
{middlewares.map((m, idx) => (
|
|
||||||
<FeatureMiddleware key={`${uid}-${idx}`} middleware={m} />
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
) : (
|
|
||||||
<Text css={{ fontStyle: 'italic', color: 'hsl(0, 0%, 56%)' }}>No additional features</Text>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AdditionalFeatures
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Badge, CSS, Flex, styled, Text } from '@traefiklabs/faency'
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { BsToggleOff, BsToggleOn } from 'react-icons/bs'
|
||||||
|
|
||||||
|
import { colorByStatus } from './Status'
|
||||||
|
|
||||||
|
import CopyButton from 'components/buttons/CopyButton'
|
||||||
|
|
||||||
|
export const ItemTitle = styled(Text, {
|
||||||
|
marginBottom: '$3',
|
||||||
|
color: 'hsl(0, 0%, 56%)',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 600,
|
||||||
|
textAlign: 'left',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})
|
||||||
|
|
||||||
|
const ItemBlockContainer = styled(Flex, {
|
||||||
|
maxWidth: '100%',
|
||||||
|
flexWrap: 'wrap !important',
|
||||||
|
rowGap: '$2',
|
||||||
|
|
||||||
|
// This forces the Tooltips to respect max-width, since we can't define
|
||||||
|
// it directly on the component, and the Chips are automatically covered.
|
||||||
|
span: {
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const FlexLimited = styled(Flex, {
|
||||||
|
maxWidth: '100%',
|
||||||
|
margin: '0 -8px -8px 0',
|
||||||
|
span: {
|
||||||
|
maxWidth: '100%',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChipsType = {
|
||||||
|
items: string[]
|
||||||
|
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
||||||
|
alignment?: 'center' | 'left'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
|
||||||
|
<FlexLimited wrap="wrap">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Badge key={index} variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
|
||||||
|
<Flex gap={1} align="center">
|
||||||
|
{item}
|
||||||
|
<CopyButton text={item} iconOnly />
|
||||||
|
</Flex>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</FlexLimited>
|
||||||
|
)
|
||||||
|
|
||||||
|
type ItemBlockType = {
|
||||||
|
title: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ItemBlock = ({ title, children }: ItemBlockType) => (
|
||||||
|
<Flex css={{ flexDirection: 'column', '&:not(:last-child)': { mb: '$5' } }}>
|
||||||
|
<ItemTitle>{title}</ItemTitle>
|
||||||
|
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const BooleanState = ({ enabled, css }: { enabled: boolean; css?: CSS }) => (
|
||||||
|
<Flex align="center" gap={2} css={{ color: '$textDefault', ...css }}>
|
||||||
|
{enabled ? (
|
||||||
|
<BsToggleOn color={colorByStatus.enabled} size={24} data-testid={`enabled-true`} />
|
||||||
|
) : (
|
||||||
|
<BsToggleOff color="inherit" size={24} data-testid={`enabled-false`} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text css={{ color: enabled ? colorByStatus.enabled : 'inherit', fontWeight: 600, fontSize: 'inherit' }}>
|
||||||
|
{enabled ? 'True' : 'False'}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ProviderName = styled(Text, {
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
fontSize: 'inherit !important',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const EmptyPlaceholder = styled(Text, {
|
||||||
|
color: 'hsl(0, 0%, 76%)',
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '700',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
})
|
||||||
@@ -1,356 +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'
|
|
||||||
import { useGetUrlWithReturnTo } from 'hooks/use-href-with-return-to'
|
|
||||||
|
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const CardItem = ({ card }) => {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const href = useGetUrlWithReturnTo(card.link)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
|
|
||||||
<FlexLink
|
|
||||||
data-testid={card.link}
|
|
||||||
onClick={(): false | void => !!card.link && navigate(href)}
|
|
||||||
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
|
|
||||||
>
|
|
||||||
<ItemTitle>{card.title}</ItemTitle>
|
|
||||||
<CardDescription>{card.description}</CardDescription>
|
|
||||||
</FlexLink>
|
|
||||||
</SpacedCard>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
|
|
||||||
return (
|
|
||||||
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
|
|
||||||
<SectionHeader icon={icon} title={title} />
|
|
||||||
<CardListColumnWrapper>
|
|
||||||
<CardListColumn>
|
|
||||||
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
|
|
||||||
{!cards && <CardSkeleton bigDescription={bigDescription} />}
|
|
||||||
{cards?.filter((c) => !!c.description).map((card, idx) => <CardItem key={`card-${idx}`} card={card} />)}
|
|
||||||
<Box css={{ height: '16px' }}> </Box>
|
|
||||||
</Flex>
|
|
||||||
</CardListColumn>
|
|
||||||
{!isLast && (
|
|
||||||
<Flex css={{ mt: '$5', mx: 'auto' }}>
|
|
||||||
<FiArrowRight color="hsl(0, 0%, 76%)" size={24} />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</CardListColumnWrapper>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlexCard = styled(Card, {
|
|
||||||
display: 'flex',
|
|
||||||
flexFlow: 'column',
|
|
||||||
flexGrow: '1',
|
|
||||||
overflowY: 'auto',
|
|
||||||
height: '600px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const NarrowFlexCard = styled(FlexCard, {
|
|
||||||
height: '400px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const ItemTitleSkeleton = styled(Box, {
|
|
||||||
height: '16px',
|
|
||||||
backgroundColor: '$slate5',
|
|
||||||
borderRadius: '3px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const ItemDescriptionSkeleton = styled(Box, {
|
|
||||||
height: '16px',
|
|
||||||
backgroundColor: '$slate5',
|
|
||||||
borderRadius: '3px',
|
|
||||||
})
|
|
||||||
|
|
||||||
type DetailSectionSkeletonType = {
|
|
||||||
narrow?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DetailSectionSkeleton = ({ narrow }: DetailSectionSkeletonType) => {
|
|
||||||
const Card = narrow ? NarrowFlexCard : FlexCard
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex css={{ flexDirection: 'column' }}>
|
|
||||||
<SectionHeader />
|
|
||||||
<Card css={{ p: '$5' }}>
|
|
||||||
<LayoutTwoCols css={{ mb: '$2' }}>
|
|
||||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
|
||||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
|
||||||
</LayoutTwoCols>
|
|
||||||
<LayoutTwoCols css={{ mb: '$5' }}>
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
|
||||||
</LayoutTwoCols>
|
|
||||||
<Flex css={{ mb: '$2' }}>
|
|
||||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ mb: '$5' }}>
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '50%' }} />
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ mb: '$2' }}>
|
|
||||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ mb: '$5' }}>
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '70%' }} />
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ mb: '$2' }}>
|
|
||||||
<ItemTitleSkeleton css={{ width: '30%' }} />
|
|
||||||
</Flex>
|
|
||||||
<Flex css={{ mb: '$5' }}>
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '50%' }} />
|
|
||||||
</Flex>
|
|
||||||
<LayoutTwoCols css={{ mb: '$2' }}>
|
|
||||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
|
||||||
<ItemTitleSkeleton css={{ width: '40%' }} />
|
|
||||||
</LayoutTwoCols>
|
|
||||||
<LayoutTwoCols css={{ mb: '$5' }}>
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
|
||||||
<ItemDescriptionSkeleton css={{ width: '90%' }} />
|
|
||||||
</LayoutTwoCols>
|
|
||||||
</Card>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DetailSectionType = SectionHeaderType & {
|
|
||||||
children?: ReactNode
|
|
||||||
noPadding?: boolean
|
|
||||||
narrow?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DetailSection = ({ icon, title, children, narrow, noPadding }: DetailSectionType) => {
|
|
||||||
const Card = narrow ? NarrowFlexCard : FlexCard
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex css={{ flexDirection: 'column' }}>
|
|
||||||
<SectionHeader icon={icon} title={title} />
|
|
||||||
<Card css={{ padding: noPadding ? 0 : '$5' }}>{children}</Card>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const FlexLimited = styled(Flex, {
|
|
||||||
maxWidth: '100%',
|
|
||||||
margin: '0 -8px -8px 0',
|
|
||||||
span: {
|
|
||||||
maxWidth: '100%',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
type ChipsType = {
|
|
||||||
items: string[]
|
|
||||||
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
|
||||||
alignment?: 'center' | 'left'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
|
|
||||||
<FlexLimited wrap="wrap">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Tooltip key={index} label={item} action="copy">
|
|
||||||
<Badge variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
|
|
||||||
{item}
|
|
||||||
</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</FlexLimited>
|
|
||||||
)
|
|
||||||
|
|
||||||
type ChipPropsListType = {
|
|
||||||
data: {
|
|
||||||
[key: string]: string
|
|
||||||
}
|
|
||||||
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ChipPropsList = ({ data, variant }: ChipPropsListType) => (
|
|
||||||
<Flex css={{ flexWrap: 'wrap' }}>
|
|
||||||
{Object.entries(data).map((entry: [string, string]) => (
|
|
||||||
<Badge key={entry[0]} variant={variant} css={{ textAlign: 'left', mr: '$2', mb: '$2' }}>
|
|
||||||
{entry[1]}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
type ItemBlockType = {
|
|
||||||
title: string
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ItemBlock = ({ title, children }: ItemBlockType) => (
|
|
||||||
<Flex css={{ flexDirection: 'column', mb: '$5' }}>
|
|
||||||
<ItemTitle>{title}</ItemTitle>
|
|
||||||
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
const LayoutCols = styled(Box, {
|
|
||||||
display: 'grid',
|
|
||||||
gridGap: '16px',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const LayoutTwoCols = styled(LayoutCols, {
|
|
||||||
gridTemplateColumns: 'repeat(2, minmax(50%, 1fr))',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const LayoutThreeCols = styled(LayoutCols, {
|
|
||||||
gridTemplateColumns: 'repeat(3, minmax(30%, 1fr))',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const BooleanState = ({ enabled }: { enabled: boolean }) => (
|
|
||||||
<Flex align="center" gap={2}>
|
|
||||||
<StatusWrapper
|
|
||||||
css={{
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: enabled ? colorByStatus.enabled : colorByStatus.disabled,
|
|
||||||
}}
|
|
||||||
data-testid={`enabled-${enabled}`}
|
|
||||||
>
|
|
||||||
{enabled ? <FiToggleRight color="#fff" size={20} /> : <FiToggleLeft color="#fff" size={20} />}
|
|
||||||
</StatusWrapper>
|
|
||||||
<Text css={{ color: enabled ? colorByStatus.enabled : colorByStatus.disabled, fontWeight: 600 }}>
|
|
||||||
{enabled ? 'True' : 'False'}
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const ProviderName = styled(Text, {
|
|
||||||
textTransform: 'capitalize',
|
|
||||||
overflowWrap: 'break-word',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const EmptyPlaceholder = styled(Text, {
|
|
||||||
color: 'hsl(0, 0%, 76%)',
|
|
||||||
fontSize: '20px',
|
|
||||||
fontWeight: '700',
|
|
||||||
lineHeight: '1.2',
|
|
||||||
})
|
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { Card, CSS, Flex, Grid, H2, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||||
|
import { Fragment, ReactNode, useMemo } from 'react'
|
||||||
|
|
||||||
|
import ScrollableCard from 'components/ScrollableCard'
|
||||||
|
import breakpoints from 'utils/breakpoints'
|
||||||
|
|
||||||
|
const StyledText = styled(Text, {
|
||||||
|
fontSize: 'inherit !important',
|
||||||
|
lineHeight: '24px',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ValText = styled(StyledText, {
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SectionTitle = ({ icon, title }: { icon?: ReactNode; title: string }) => {
|
||||||
|
return (
|
||||||
|
<Flex gap={2} align="center" css={{ color: '$headingDefault' }}>
|
||||||
|
{icon && icon}
|
||||||
|
<H2 css={{ fontSize: '$5' }}>{title}</H2>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailsCardProps = {
|
||||||
|
css?: CSS
|
||||||
|
keyColumns?: number
|
||||||
|
items: { key: string; val: string | React.ReactElement; stackVertical?: boolean; forceNewRow?: boolean }[]
|
||||||
|
minKeyWidth?: string
|
||||||
|
maxKeyWidth?: string
|
||||||
|
testidPrefix?: string
|
||||||
|
testId?: string
|
||||||
|
title?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
scrollable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DetailsCard({
|
||||||
|
css = {},
|
||||||
|
keyColumns = 2,
|
||||||
|
items,
|
||||||
|
minKeyWidth,
|
||||||
|
maxKeyWidth,
|
||||||
|
testidPrefix = 'definition',
|
||||||
|
testId,
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
scrollable = false,
|
||||||
|
}: DetailsCardProps) {
|
||||||
|
const ParentComponent = useMemo(() => {
|
||||||
|
if (scrollable) {
|
||||||
|
return ScrollableCard
|
||||||
|
}
|
||||||
|
return Card
|
||||||
|
}, [scrollable])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex as="section" direction="column" gap={2} css={{ ...css }} data-testid={testId || `${testidPrefix}-section`}>
|
||||||
|
{title ? <SectionTitle icon={icon} title={title} /> : null}
|
||||||
|
<ParentComponent css={{ flex: 1 }}>
|
||||||
|
<Grid
|
||||||
|
css={{
|
||||||
|
gap: '$2 $3',
|
||||||
|
gridTemplateColumns: maxKeyWidth
|
||||||
|
? `repeat(${keyColumns}, minmax(auto, ${maxKeyWidth}) 1fr)`
|
||||||
|
: `repeat(${keyColumns}, auto 1fr)`,
|
||||||
|
[`@media (max-width:${breakpoints.laptop}px)`]: {
|
||||||
|
gridTemplateColumns: maxKeyWidth ? `minmax(auto, ${maxKeyWidth}) 1fr` : 'auto 1fr',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
// Handle forceNewRow props
|
||||||
|
const cellsBeforeThis = items.slice(0, index).reduce((count, prevItem) => {
|
||||||
|
if (prevItem.stackVertical) return count + keyColumns
|
||||||
|
return count + 1
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
const needsEmptyCell = item.forceNewRow && cellsBeforeThis % keyColumns !== 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={index}>
|
||||||
|
{needsEmptyCell && (
|
||||||
|
<>
|
||||||
|
<div />
|
||||||
|
<div />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.stackVertical ? (
|
||||||
|
<Flex direction="column" gap={2} css={{ gridColumn: 'span 2' }}>
|
||||||
|
<StyledText
|
||||||
|
css={{
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: minKeyWidth,
|
||||||
|
maxWidth: maxKeyWidth,
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.key}
|
||||||
|
</StyledText>
|
||||||
|
{typeof item.val === 'string' ? (
|
||||||
|
<ValText>{item.val}</ValText>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
css={{
|
||||||
|
'> *': {
|
||||||
|
height: 'fit-content',
|
||||||
|
},
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.val}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Grid>
|
||||||
|
{index < keyColumns
|
||||||
|
? items
|
||||||
|
.filter((hiddenItem) => hiddenItem.key != item.key)
|
||||||
|
.map((hiddenItem, jndex) => (
|
||||||
|
<StyledText
|
||||||
|
key={`hidden-${index}-${jndex}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
css={{
|
||||||
|
gridArea: '1 / 1',
|
||||||
|
fontWeight: 600,
|
||||||
|
visibility: 'hidden',
|
||||||
|
maxWidth: maxKeyWidth,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{hiddenItem.key}
|
||||||
|
</StyledText>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
<StyledText
|
||||||
|
css={{
|
||||||
|
gridArea: '1 / 1',
|
||||||
|
fontWeight: 600,
|
||||||
|
minWidth: minKeyWidth,
|
||||||
|
maxWidth: maxKeyWidth,
|
||||||
|
overflowWrap: 'break-word',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.key}
|
||||||
|
</StyledText>
|
||||||
|
</Grid>
|
||||||
|
{typeof item.val === 'string' ? (
|
||||||
|
<ValText css={{ flex: 1 }}>{item.val}</ValText>
|
||||||
|
) : (
|
||||||
|
<Flex
|
||||||
|
align="center"
|
||||||
|
css={{
|
||||||
|
alignSelf: 'start',
|
||||||
|
'> *': {
|
||||||
|
height: 'fit-content',
|
||||||
|
},
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.val}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</ParentComponent>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DetailsCardSkeleton({
|
||||||
|
keyColumns = 2,
|
||||||
|
rows = 3,
|
||||||
|
testidPrefix = 'definition',
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
}: { rows?: number } & Omit<DetailsCardProps, 'items'>) {
|
||||||
|
return (
|
||||||
|
<Flex as="section" direction="column" gap={2} data-testid={`${testidPrefix}-section-skeleton`}>
|
||||||
|
{title ? <SectionTitle icon={icon} title={title} /> : <Skeleton css={{ height: '$5', width: '150px' }} />}
|
||||||
|
<Card css={{ flex: 1 }}>
|
||||||
|
<Grid
|
||||||
|
css={{
|
||||||
|
gap: '$2 $3',
|
||||||
|
gridTemplateColumns: `repeat(${keyColumns}, auto 1fr)`,
|
||||||
|
[`@media (max-width:${breakpoints.laptop}px)`]: { gridTemplateColumns: 'auto 1fr' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{[...Array(rows * keyColumns)].map((_, idx) => (
|
||||||
|
<Fragment key={idx}>
|
||||||
|
<Skeleton css={{ height: '$5', width: '96px' }} />
|
||||||
|
<Skeleton css={{ height: '$5', width: '192px' }} />
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
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 = {
|
type GenericTableProps = {
|
||||||
items: (number | string)[]
|
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])
|
const border = useMemo(() => `1px solid $${status === 'error' ? 'textRed' : 'tableRowBorder'}`, [status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,23 +20,31 @@ export default function GenericTable({ items, status }: GenericTableProps) {
|
|||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<AriaTr key={index}>
|
<AriaTr key={index}>
|
||||||
<AriaTd css={{ p: '$2' }}>
|
<AriaTd css={{ p: '$2' }}>
|
||||||
<Tooltip label={item.toString()} action="copy">
|
<Flex align="start" gap={2} css={{ width: 'fit-content' }}>
|
||||||
<Flex align="start" gap={2} css={{ width: 'fit-content' }}>
|
{status ? (
|
||||||
{status ? (
|
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={14} />
|
||||||
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={16} />
|
) : (
|
||||||
) : (
|
<Text css={{ fontFamily: 'monospace', mt: '1px', userSelect: 'none' }} variant="subtle">
|
||||||
<Text css={{ fontFamily: 'monospace', mt: '1px', userSelect: 'none' }} variant="subtle">
|
{index}
|
||||||
{index}
|
</Text>
|
||||||
</Text>
|
)}
|
||||||
)}
|
{copyable ? (
|
||||||
|
<CopyableText
|
||||||
|
text={String(item)}
|
||||||
|
css={{
|
||||||
|
fontFamily: status === 'error' ? 'monospace' : undefined,
|
||||||
|
color: status === 'error' ? '$textRed' : 'initial',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Text
|
<Text
|
||||||
css={{ fontFamily: status === 'error' ? 'monospace' : undefined }}
|
css={{ fontFamily: status === 'error' ? 'monospace' : undefined }}
|
||||||
variant={status === 'error' ? 'red' : undefined}
|
variant={status === 'error' ? 'red' : undefined}
|
||||||
>
|
>
|
||||||
{item}
|
{item}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
)}
|
||||||
</Tooltip>
|
</Flex>
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</AriaTr>
|
</AriaTr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { Box, Flex, H3, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { FiLayers } from 'react-icons/fi'
|
|
||||||
|
|
||||||
import { DetailSection, EmptyPlaceholder, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
|
|
||||||
import GenericTable from './GenericTable'
|
|
||||||
import { RenderUnknownProp } from './RenderUnknownProp'
|
|
||||||
import { ResourceStatus } from './ResourceStatus'
|
|
||||||
|
|
||||||
import { EmptyIcon } from 'components/icons/EmptyIcon'
|
|
||||||
import ProviderIcon from 'components/icons/providers'
|
|
||||||
import { Middleware, RouterDetailType } from 'hooks/use-resource-detail'
|
|
||||||
import { parseMiddlewareType } from 'libs/parsers'
|
|
||||||
|
|
||||||
const Separator = styled('hr', {
|
|
||||||
border: 'none',
|
|
||||||
background: '$tableRowBorder',
|
|
||||||
margin: '0 0 24px',
|
|
||||||
height: '1px',
|
|
||||||
minHeight: '1px',
|
|
||||||
})
|
|
||||||
|
|
||||||
const filterMiddlewareProps = (middleware: Middleware): string[] => {
|
|
||||||
const filteredProps = [] as string[]
|
|
||||||
const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers']
|
|
||||||
|
|
||||||
Object.keys(middleware).map((propName) => {
|
|
||||||
if (!propsToRemove.includes(propName)) {
|
|
||||||
filteredProps.push(propName)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return filteredProps
|
|
||||||
}
|
|
||||||
|
|
||||||
type RenderMiddlewareProps = {
|
|
||||||
middleware: Middleware
|
|
||||||
withHeader?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RenderMiddleware = ({ middleware, withHeader }: RenderMiddlewareProps) => (
|
|
||||||
<Flex key={middleware.name} css={{ flexDirection: 'column' }}>
|
|
||||||
{withHeader && <H3 css={{ mb: '$7', overflowWrap: 'break-word' }}>{middleware.name}</H3>}
|
|
||||||
<LayoutTwoCols>
|
|
||||||
{(middleware.type || middleware.plugin) && (
|
|
||||||
<ItemBlock title="Type">
|
|
||||||
<Text css={{ lineHeight: '32px', overflowWrap: 'break-word' }}>{parseMiddlewareType(middleware)}</Text>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{middleware.provider && (
|
|
||||||
<ItemBlock title="Provider">
|
|
||||||
<ProviderIcon name={middleware.provider} />
|
|
||||||
<ProviderName css={{ ml: '$2' }}>{middleware.provider}</ProviderName>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
</LayoutTwoCols>
|
|
||||||
{middleware.status && (
|
|
||||||
<ItemBlock title="Status">
|
|
||||||
<ResourceStatus status={middleware.status} withLabel />
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{middleware.error && (
|
|
||||||
<ItemBlock title="Errors">
|
|
||||||
<GenericTable items={middleware.error} status="error" />
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{middleware.plugin &&
|
|
||||||
Object.keys(middleware.plugin).map((pluginName) => (
|
|
||||||
<RenderUnknownProp key={pluginName} name={pluginName} prop={middleware.plugin?.[pluginName]} />
|
|
||||||
))}
|
|
||||||
{filterMiddlewareProps(middleware).map((propName) => (
|
|
||||||
<RenderUnknownProp
|
|
||||||
key={propName}
|
|
||||||
name={propName}
|
|
||||||
prop={middleware[propName]}
|
|
||||||
removeTitlePrefix={middleware.type}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
)
|
|
||||||
|
|
||||||
const MiddlewarePanel = ({ data }: { data: RouterDetailType }) => (
|
|
||||||
<DetailSection icon={<FiLayers size={20} />} title="Middlewares">
|
|
||||||
{data.middlewares ? (
|
|
||||||
data.middlewares.map((middleware, index) => (
|
|
||||||
<Box key={middleware.name}>
|
|
||||||
<RenderMiddleware middleware={middleware} withHeader />
|
|
||||||
{data.middlewares && index < data.middlewares.length - 1 && <Separator />}
|
|
||||||
</Box>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
|
|
||||||
<Box
|
|
||||||
css={{
|
|
||||||
width: 88,
|
|
||||||
svg: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EmptyIcon />
|
|
||||||
</Box>
|
|
||||||
<EmptyPlaceholder css={{ mt: '$3' }}>
|
|
||||||
There are no
|
|
||||||
<br />
|
|
||||||
Middlewares configured
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default MiddlewarePanel
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Card, Flex, Skeleton } from '@traefiklabs/faency'
|
||||||
|
import { FiAlertTriangle } from 'react-icons/fi'
|
||||||
|
|
||||||
|
import { SectionTitle } from './DetailsCard'
|
||||||
|
import GenericTable from './GenericTable'
|
||||||
|
|
||||||
|
const ResourceErrors = ({ errors }: { errors: string[] }) => {
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={2}>
|
||||||
|
<SectionTitle title="Errors" icon={<FiAlertTriangle color="hsl(347, 100%, 60.0%)" size={20} />} />
|
||||||
|
<Card>
|
||||||
|
<GenericTable items={errors} status="error" copyable />
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ResourceErrorsSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<Flex direction="column" gap={2}>
|
||||||
|
<Skeleton css={{ width: 200 }} />
|
||||||
|
<Card css={{ width: '100%', height: 150, gap: '$3', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{[...Array(4)].map((_, idx) => (
|
||||||
|
<Skeleton key={`1-${idx}`} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResourceErrors
|
||||||
@@ -1,25 +1,26 @@
|
|||||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
import { Box, Flex, styled, Text } from '@traefiklabs/faency'
|
||||||
import { ReactNode } from 'react'
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
|
import { colorByStatus, iconByStatus } from 'components/resources/Status'
|
||||||
|
|
||||||
export const StatusWrapper = styled(Flex, {
|
export const StatusWrapper = styled(Flex, {
|
||||||
height: '32px',
|
height: '24px',
|
||||||
width: '32px',
|
width: '24px',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
})
|
})
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
status: StatusType
|
status: Resource.Status
|
||||||
label?: string
|
label?: string
|
||||||
withLabel?: boolean
|
withLabel?: boolean
|
||||||
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Value = { color: string; icon: ReactNode; label: string }
|
type Value = { color: string; icon: ReactNode; label: string }
|
||||||
|
|
||||||
export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props) => {
|
||||||
const valuesByStatus: { [key in StatusType]: Value } = {
|
const valuesByStatus: { [key in Resource.Status]: Value } = {
|
||||||
info: {
|
info: {
|
||||||
color: colorByStatus.info,
|
color: colorByStatus.info,
|
||||||
icon: iconByStatus.info,
|
icon: iconByStatus.info,
|
||||||
@@ -50,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
|||||||
icon: iconByStatus.disabled,
|
icon: iconByStatus.disabled,
|
||||||
label: 'Error',
|
label: 'Error',
|
||||||
},
|
},
|
||||||
|
loading: {
|
||||||
|
color: colorByStatus.loading,
|
||||||
|
icon: iconByStatus.loading,
|
||||||
|
label: 'Loading...',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = valuesByStatus[status]
|
const values = valuesByStatus[status]
|
||||||
@@ -59,12 +65,12 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex css={{ alignItems: 'center' }} data-testid={status}>
|
<Flex align="center" css={{ width: size, height: size }} data-testid={status}>
|
||||||
<StatusWrapper css={{ alignItems: 'center', justifyContent: 'center', backgroundColor: values.color }}>
|
<Box css={{ color: values.color, width: size, height: size }}>{values.icon}</Box>
|
||||||
{values.icon}
|
|
||||||
</StatusWrapper>
|
|
||||||
{withLabel && values.label && (
|
{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>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Badge, Text } from '@traefiklabs/faency'
|
|
||||||
import { FiInfo } from 'react-icons/fi'
|
|
||||||
|
|
||||||
import { DetailSection, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
|
|
||||||
import GenericTable from './GenericTable'
|
|
||||||
import { ResourceStatus } from './ResourceStatus'
|
|
||||||
|
|
||||||
import ProviderIcon from 'components/icons/providers'
|
|
||||||
import Tooltip from 'components/Tooltip'
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: ResourceDetailDataType
|
|
||||||
}
|
|
||||||
|
|
||||||
const RouterPanel = ({ data }: Props) => (
|
|
||||||
<DetailSection icon={<FiInfo size={20} />} title="Router Details">
|
|
||||||
<LayoutTwoCols>
|
|
||||||
{data.status && (
|
|
||||||
<ItemBlock title="Status">
|
|
||||||
<ResourceStatus status={data.status} withLabel />
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{data.provider && (
|
|
||||||
<ItemBlock title="Provider">
|
|
||||||
<ProviderIcon name={data.provider} />
|
|
||||||
<ProviderName css={{ ml: '$2' }}>{data.provider}</ProviderName>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{data.priority && (
|
|
||||||
<ItemBlock title="Priority">
|
|
||||||
<Tooltip label={data.priority.toString()} action="copy">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.priority.toString()}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
</LayoutTwoCols>
|
|
||||||
{data.rule ? (
|
|
||||||
<ItemBlock title="Rule">
|
|
||||||
<Tooltip label={data.rule} action="copy">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.rule}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</ItemBlock>
|
|
||||||
) : null}
|
|
||||||
{data.name && (
|
|
||||||
<ItemBlock title="Name">
|
|
||||||
<Tooltip label={data.name} action="copy">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.name}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{!!data.using && data.using && data.using.length > 0 && (
|
|
||||||
<ItemBlock title="Entrypoints">
|
|
||||||
{data.using.map((ep) => (
|
|
||||||
<Tooltip key={ep} label={ep} action="copy">
|
|
||||||
<Badge css={{ mr: '$2' }}>{ep}</Badge>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{data.service && (
|
|
||||||
<ItemBlock title="Service">
|
|
||||||
<Tooltip label={data.service} action="copy">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.service}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{data.error && (
|
|
||||||
<ItemBlock title="Errors">
|
|
||||||
<GenericTable items={data.error} status="error" />
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default RouterPanel
|
|
||||||
@@ -1,49 +1,50 @@
|
|||||||
import { Box, CSS } from '@traefiklabs/faency'
|
import { Box, CSS } from '@traefiklabs/faency'
|
||||||
import { ReactNode } from 'react'
|
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 Resource.Status]: ReactNode } = {
|
||||||
|
info: <FiAlertCircle color="currentColor" size={20} />,
|
||||||
export const iconByStatus: { [key in StatusType]: ReactNode } = {
|
success: <FiCheckCircle color="currentColor" size={20} />,
|
||||||
info: <FiAlertCircle color="white" size={20} />,
|
warning: <FiAlertCircle color="currentColor" size={20} />,
|
||||||
success: <FiCheckCircle color="white" size={20} />,
|
error: <FiAlertTriangle color="currentColor" size={20} />,
|
||||||
warning: <FiAlertCircle color="white" size={20} />,
|
enabled: <FiCheckCircle color="currentColor" size={20} />,
|
||||||
error: <FiAlertTriangle color="white" size={20} />,
|
disabled: <FiAlertTriangle color="currentColor" size={20} />,
|
||||||
enabled: <FiCheckCircle color="white" size={20} />,
|
loading: <FiLoader color="currentColor" size={20} />,
|
||||||
disabled: <FiAlertTriangle color="white" size={20} />,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Please notice: dark and light colors have the same values.
|
// 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%)',
|
info: 'hsl(220, 67%, 51%)',
|
||||||
success: '#30A46C',
|
success: '#30A46C',
|
||||||
warning: 'hsl(24 94.0% 50.0%)',
|
warning: 'hsl(24 94.0% 50.0%)',
|
||||||
error: 'hsl(347, 100%, 60.0%)',
|
error: 'hsl(347, 100%, 60.0%)',
|
||||||
enabled: '#30A46C',
|
enabled: '#30A46C',
|
||||||
disabled: 'hsl(347, 100%, 60.0%)',
|
disabled: 'hsl(347, 100%, 60.0%)',
|
||||||
|
loading: 'hsla(0, 0%, 100%, 0.51)',
|
||||||
}
|
}
|
||||||
|
|
||||||
type StatusProps = {
|
type StatusProps = {
|
||||||
css?: CSS
|
css?: CSS
|
||||||
size?: number
|
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 }) => {
|
const Icon = ({ size }: { size: number }) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'info':
|
case 'info':
|
||||||
return <FiAlertCircle color="white" size={size} />
|
return <FiAlertCircle color={color} size={size} />
|
||||||
case 'success':
|
case 'success':
|
||||||
return <FiCheckCircle color="white" size={size} />
|
return <FiCheckCircle color={color} size={size} />
|
||||||
case 'warning':
|
case 'warning':
|
||||||
return <FiAlertCircle color="white" size={size} />
|
return <FiAlertCircle color={color} size={size} />
|
||||||
case 'error':
|
case 'error':
|
||||||
return <FiAlertTriangle color="white" size={size} />
|
return <FiAlertTriangle color={color} size={size} />
|
||||||
case 'enabled':
|
case 'enabled':
|
||||||
return <FiCheckCircle color="white" size={size} />
|
return <FiCheckCircle color={color} size={size} />
|
||||||
case 'disabled':
|
case 'disabled':
|
||||||
return <FiAlertTriangle color="white" size={size} />
|
return <FiAlertTriangle color={color} size={size} />
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
import { Badge, Box, Flex, Text } from '@traefiklabs/faency'
|
|
||||||
import { FiShield } from 'react-icons/fi'
|
|
||||||
|
|
||||||
import { BooleanState, DetailSection, EmptyPlaceholder, ItemBlock } from './DetailSections'
|
|
||||||
|
|
||||||
import { EmptyIcon } from 'components/icons/EmptyIcon'
|
|
||||||
import { RouterDetailType } from 'hooks/use-resource-detail'
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
data: RouterDetailType
|
|
||||||
}
|
|
||||||
|
|
||||||
const TlsPanel = ({ data }: Props) => (
|
|
||||||
<DetailSection icon={<FiShield size={20} />} title="TLS">
|
|
||||||
{data.tls ? (
|
|
||||||
<Flex css={{ flexDirection: 'column' }}>
|
|
||||||
<ItemBlock title="TLS">
|
|
||||||
<BooleanState enabled />
|
|
||||||
</ItemBlock>
|
|
||||||
{data.tls.options && (
|
|
||||||
<ItemBlock title="Options">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.options}</Text>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
<ItemBlock title="PassThrough">
|
|
||||||
<BooleanState enabled={!!data.tls.passthrough} />
|
|
||||||
</ItemBlock>
|
|
||||||
{data.tls.certResolver && (
|
|
||||||
<ItemBlock title="Certificate Resolver">
|
|
||||||
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.certResolver}</Text>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
{data.tls.domains && (
|
|
||||||
<ItemBlock title="Domains">
|
|
||||||
<Flex css={{ flexDirection: 'column' }}>
|
|
||||||
{data.tls.domains?.map((domain) => (
|
|
||||||
<Flex key={domain.main} css={{ flexWrap: 'wrap' }}>
|
|
||||||
<a href={`//${domain.main}`}>
|
|
||||||
<Badge variant="blue" css={{ mr: '$2', mb: '$2', color: '$primary', borderColor: '$primary' }}>
|
|
||||||
{domain.main}
|
|
||||||
</Badge>
|
|
||||||
</a>
|
|
||||||
{domain.sans?.map((sub) => (
|
|
||||||
<a key={sub} href={`//${sub}`}>
|
|
||||||
<Badge css={{ mr: '$2', mb: '$2' }}>{sub}</Badge>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</ItemBlock>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
) : (
|
|
||||||
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
|
|
||||||
<Box
|
|
||||||
css={{
|
|
||||||
width: 88,
|
|
||||||
svg: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EmptyIcon />
|
|
||||||
</Box>
|
|
||||||
<EmptyPlaceholder css={{ mt: '$3' }}>
|
|
||||||
There is no
|
|
||||||
<br />
|
|
||||||
TLS configured
|
|
||||||
</EmptyPlaceholder>
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</DetailSection>
|
|
||||||
)
|
|
||||||
|
|
||||||
export default TlsPanel
|
|
||||||
@@ -5,7 +5,7 @@ import { Doughnut } from 'react-chartjs-2'
|
|||||||
import { FaArrowRightLong } from 'react-icons/fa6'
|
import { FaArrowRightLong } from 'react-icons/fa6'
|
||||||
import { Link as RouterLink, useNavigate } from 'react-router-dom'
|
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'
|
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 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',
|
status: 'enabled',
|
||||||
label: 'success',
|
label: 'success',
|
||||||
@@ -80,7 +80,7 @@ const CustomLegend = ({
|
|||||||
total,
|
total,
|
||||||
linkTo,
|
linkTo,
|
||||||
}: {
|
}: {
|
||||||
status: StatusType
|
status: Resource.Status
|
||||||
label: string
|
label: string
|
||||||
count: number
|
count: number
|
||||||
total: number
|
total: number
|
||||||
|
|||||||
@@ -1,96 +1,24 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTh, AriaThead, AriaTr, Box, Flex, styled } from '@traefiklabs/faency'
|
import { Flex } from '@traefiklabs/faency'
|
||||||
import { orderBy } from 'lodash'
|
import { orderBy } from 'lodash'
|
||||||
import { useContext, useEffect, useMemo } from 'react'
|
import { useContext, useEffect, useMemo } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { SectionHeader } from 'components/resources/DetailSections'
|
import { SectionTitle } from './DetailsCard'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
|
||||||
|
import AriaTableSkeleton from 'components/tables/AriaTableSkeleton'
|
||||||
|
import PaginatedTable from 'components/tables/PaginatedTable'
|
||||||
import { ToastContext } from 'contexts/toasts'
|
import { ToastContext } from 'contexts/toasts'
|
||||||
import { MiddlewareDetailType, ServiceDetailType } from 'hooks/use-resource-detail'
|
|
||||||
import { makeRowRender } from 'pages/http/HttpRouters'
|
import { makeRowRender } from 'pages/http/HttpRouters'
|
||||||
|
|
||||||
type UsedByRoutersSectionProps = {
|
type UsedByRoutersSectionProps = {
|
||||||
data: ServiceDetailType | MiddlewareDetailType
|
data: Service.Details | Middleware.DetailsData
|
||||||
protocol?: string
|
protocol?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const SkeletonContent = styled(Box, {
|
|
||||||
backgroundColor: '$slate5',
|
|
||||||
height: '14px',
|
|
||||||
minWidth: '50px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
margin: '8px',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const UsedByRoutersSkeleton = () => (
|
export const UsedByRoutersSkeleton = () => (
|
||||||
<Flex css={{ flexDirection: 'column', mt: '40px' }}>
|
<Flex gap={2} css={{ flexDirection: 'column', mt: '40px' }}>
|
||||||
<SectionHeader />
|
<SectionTitle title="Used by routers" />
|
||||||
<AriaTable>
|
<AriaTableSkeleton columns={8} />
|
||||||
<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>
|
</Flex>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,29 +46,38 @@ export const UsedByRoutersSection = ({ data, protocol = 'http' }: UsedByRoutersS
|
|||||||
)
|
)
|
||||||
}, [addToast, routersNotFound])
|
}, [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) {
|
if (!routersFound || routersFound.length <= 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex css={{ flexDirection: 'column', mt: '$5' }}>
|
<Flex gap={2} css={{ flexDirection: 'column' }}>
|
||||||
<SectionHeader title="Used by Routers" />
|
<SectionTitle title="Used by routers" />
|
||||||
|
<PaginatedTable
|
||||||
<AriaTable data-testid="routers-table">
|
data={routersFound}
|
||||||
<AriaThead>
|
columns={columns}
|
||||||
<AriaTr>
|
itemsPerPage={10}
|
||||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
testId="routers-table"
|
||||||
{protocol !== 'udp' ? <SortableTh css={{ width: '40px' }} label="TLS" /> : null}
|
renderRow={renderRow}
|
||||||
{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>
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export const getProviderFromName = (serviceName: string, defaultProvider: string): string => {
|
||||||
|
const [, provider] = serviceName.split('@')
|
||||||
|
return provider || defaultProvider
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -39,7 +39,7 @@ const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record<string, Record<string, string>>
|
|||||||
},
|
},
|
||||||
udp: {
|
udp: {
|
||||||
routers: 'UDP router',
|
routers: 'UDP router',
|
||||||
services: 'TCP service',
|
services: 'UDP service',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ const RETURN_TO_LABEL_OVERRIDES_PLURAL: Record<string, Record<string, string>> =
|
|||||||
},
|
},
|
||||||
udp: {
|
udp: {
|
||||||
routers: 'UDP routers',
|
routers: 'UDP routers',
|
||||||
services: 'TCP services',
|
services: 'UDP services',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,115 +2,8 @@ import useSWR from 'swr'
|
|||||||
|
|
||||||
import fetchMany from 'libs/fetchMany'
|
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 = {
|
type ResourceDetailType = {
|
||||||
data?: ResourceDetailDataType
|
data?: Resource.DetailsData
|
||||||
error?: Error
|
error?: Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,7 +21,7 @@ export const useResourceDetail = (name: string, resource: string, protocol = 'ht
|
|||||||
}
|
}
|
||||||
|
|
||||||
const firstError = error || entryPointsError || middlewaresError || routersError
|
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
|
const hasMiddlewares = validMiddlewares
|
||||||
? validMiddlewares.length > 0
|
? validMiddlewares.length > 0
|
||||||
: routeDetail.middlewares && routeDetail.middlewares.length > 0
|
: routeDetail.middlewares && routeDetail.middlewares.length > 0
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@traefiklabs/faency'
|
} from '@traefiklabs/faency'
|
||||||
import { useContext, useEffect, useMemo, useState } from 'react'
|
import { useContext, useMemo } from 'react'
|
||||||
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi'
|
import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from '../Page'
|
|||||||
import ThemeSwitcher from 'components/ThemeSwitcher'
|
import ThemeSwitcher from 'components/ThemeSwitcher'
|
||||||
import { VersionContext } from 'contexts/version'
|
import { VersionContext } from 'contexts/version'
|
||||||
import { useRouterReturnTo } from 'hooks/use-href-with-return-to'
|
import { useRouterReturnTo } from 'hooks/use-href-with-return-to'
|
||||||
|
import useHubUpgradeButton from 'hooks/use-hub-upgrade-button'
|
||||||
import { useIsDarkMode } from 'hooks/use-theme'
|
import { useIsDarkMode } from 'hooks/use-theme'
|
||||||
|
|
||||||
const TopNavBarBackLink = () => {
|
const TopNavBarBackLink = () => {
|
||||||
@@ -43,8 +45,7 @@ const TopNavBarBackLink = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
|
||||||
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
|
const { version } = useContext(VersionContext)
|
||||||
const { showHubButton, version } = useContext(VersionContext)
|
|
||||||
const isDarkMode = useIsDarkMode()
|
const isDarkMode = useIsDarkMode()
|
||||||
|
|
||||||
const parsedVersion = useMemo(() => {
|
const parsedVersion = useMemo(() => {
|
||||||
@@ -58,101 +59,86 @@ export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?:
|
|||||||
return matches ? 'v' + matches[1] : 'master'
|
return matches ? 'v' + matches[1] : 'master'
|
||||||
}, [version])
|
}, [version])
|
||||||
|
|
||||||
useEffect(() => {
|
const { signatureVerified, scriptBlobUrl, isCustomElementDefined } = useHubUpgradeButton()
|
||||||
if (!showHubButton) {
|
|
||||||
setHasHubButtonComponent(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (customElements.get('hub-button-app')) {
|
const displayUpgradeToHubButton = useMemo(
|
||||||
setHasHubButtonComponent(true)
|
() => !noHubButton && signatureVerified && (!!scriptBlobUrl || isCustomElementDefined),
|
||||||
return
|
[isCustomElementDefined, noHubButton, scriptBlobUrl, signatureVerified],
|
||||||
}
|
)
|
||||||
|
|
||||||
const scripts: HTMLScriptElement[] = []
|
|
||||||
const createScript = (scriptSrc: string): HTMLScriptElement => {
|
|
||||||
const script = document.createElement('script')
|
|
||||||
script.src = scriptSrc
|
|
||||||
script.async = true
|
|
||||||
script.onload = () => {
|
|
||||||
setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined)
|
|
||||||
}
|
|
||||||
scripts.push(script)
|
|
||||||
return script
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source: https://github.com/traefik/traefiklabs-hub-button-app
|
|
||||||
document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js'))
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Remove the scripts on unmount.
|
|
||||||
scripts.forEach((script) => {
|
|
||||||
if (script.parentNode) {
|
|
||||||
script.parentNode.removeChild(script)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [showHubButton])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex as="nav" role="navigation" justify="space-between" align="center" css={{ mb: '$6', ...css }}>
|
<>
|
||||||
<TopNavBarBackLink />
|
{displayUpgradeToHubButton && (
|
||||||
<Flex gap={2} align="center">
|
<Helmet>
|
||||||
{!noHubButton && hasHubButtonComponent && (
|
<meta
|
||||||
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
httpEquiv="Content-Security-Policy"
|
||||||
<hub-button-app
|
content="script-src 'self' blob: 'unsafe-inline'; object-src 'none'; base-uri 'self';"
|
||||||
key={`dark-mode-${isDarkMode}`}
|
/>
|
||||||
style={{ backgroundColor: isDarkMode ? DARK_PRIMARY_COLOR : LIGHT_PRIMARY_COLOR, fontWeight: 'inherit' }}
|
<script src={scriptBlobUrl as string} type="module"></script>
|
||||||
/>
|
</Helmet>
|
||||||
</Box>
|
)}
|
||||||
)}
|
<Flex as="nav" role="navigation" justify="space-between" align="center" css={{ mb: '$6', ...css }}>
|
||||||
<Tooltip content="Sponsor" side="bottom">
|
<TopNavBarBackLink />
|
||||||
<Link href="https://github.com/sponsors/traefik" target="_blank">
|
<Flex gap={2} align="center">
|
||||||
<Button as="div" ghost css={{ px: '$2', boxShadow: 'none' }}>
|
{displayUpgradeToHubButton && (
|
||||||
<FiHeart size={20} color="#db61a2" />
|
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
|
||||||
</Button>
|
<hub-button-app
|
||||||
</Link>
|
key={`dark-mode-${isDarkMode}`}
|
||||||
</Tooltip>
|
style={{
|
||||||
<ThemeSwitcher />
|
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>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
<Button ghost variant="secondary" css={{ px: '$2', boxShadow: 'none' }} data-testid="help-menu">
|
||||||
<FiHelpCircle size={20} />
|
<FiHelpCircle size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuPortal>
|
<DropdownMenuPortal>
|
||||||
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
<DropdownMenuContent align="end" css={{ zIndex: 9999 }}>
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
<Link
|
<Link
|
||||||
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
href={`https://doc.traefik.io/traefik/${parsedVersion}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={2}>
|
<Flex align="center" gap={2}>
|
||||||
<FiBookOpen size={20} />
|
<FiBookOpen size={20} />
|
||||||
<Text>Documentation</Text>
|
<Text>Documentation</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
<DropdownMenuItem css={{ height: '$6', cursor: 'pointer' }}>
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/traefik/traefik/"
|
href="https://github.com/traefik/traefik/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
css={{ textDecoration: 'none', '&:hover': { textDecoration: 'none' } }}
|
||||||
>
|
>
|
||||||
<Flex align="center" gap={2}>
|
<Flex align="center" gap={2}>
|
||||||
<FiGithub size={20} />
|
<FiGithub size={20} />
|
||||||
<Text>Github Repository</Text>
|
<Text>Github Repository</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuPortal>
|
</DropdownMenuPortal>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
type ObjectWithMessage = {
|
|
||||||
message?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getValidData = <T extends ObjectWithMessage>(data?: T[]): T[] =>
|
|
||||||
data ? data.filter((item) => !item.message) : []
|
|
||||||
export const getErrorData = <T extends ObjectWithMessage>(data?: T[]): T[] =>
|
|
||||||
data ? data.filter((item) => !!item.message) : []
|
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { Middleware } from 'hooks/use-resource-detail'
|
export const parseMiddlewareType = (middleware: Middleware.Props): string | undefined => {
|
||||||
|
|
||||||
export const parseMiddlewareType = (middleware: Middleware): string | undefined => {
|
|
||||||
if (middleware.plugin) {
|
if (middleware.plugin) {
|
||||||
const pluginObject = middleware.plugin || {}
|
const pluginObject = middleware.plugin || {}
|
||||||
const [pluginName] = Object.keys(pluginObject)
|
const [pluginName] = Object.keys(pluginObject)
|
||||||
|
|||||||
@@ -63,6 +63,19 @@
|
|||||||
},
|
},
|
||||||
"forwardedHeaders": {},
|
"forwardedHeaders": {},
|
||||||
"name": "web-secured"
|
"name": "web-secured"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"address": ":443",
|
||||||
|
"transport": {
|
||||||
|
"lifeCycle": {
|
||||||
|
"graceTimeOut": 10000000000
|
||||||
|
},
|
||||||
|
"respondingTimeouts": {
|
||||||
|
"idleTimeout": 180000000000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forwardedHeaders": {},
|
||||||
|
"name": "web-secured-longer-name"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"address": ":8100",
|
"address": ":8100",
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"addPrefix": {
|
|
||||||
"prefix": "/foo"
|
|
||||||
},
|
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": ["web@docker"],
|
"usedBy": ["web@docker"],
|
||||||
"name": "add-foo@docker",
|
"name": "add-foo@docker",
|
||||||
"type": "addprefix",
|
"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": {
|
"redirectScheme": {
|
||||||
@@ -55,14 +66,21 @@
|
|||||||
"addPrefix": {
|
"addPrefix": {
|
||||||
"prefix": "/path",
|
"prefix": "/path",
|
||||||
"aCustomObject": {
|
"aCustomObject": {
|
||||||
"array of arrays": [[1, 2], [3, 4]],
|
"array of arrays": [
|
||||||
|
[1, 2],
|
||||||
|
[3, 4]
|
||||||
|
],
|
||||||
"array of objects": [{ "some": "value" }, { "another": "value" }],
|
"array of objects": [{ "some": "value" }, { "another": "value" }],
|
||||||
"array of booleans": [true, false, true],
|
"array of booleans": [true, false, true],
|
||||||
"array of numbers": [10, 100, 1000],
|
"array of numbers": [10, 100, 1000],
|
||||||
"array of strings": ["value1", "value2"]
|
"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",
|
"status": "enabled",
|
||||||
"usedBy": ["foo@docker", "bar@file"],
|
"usedBy": ["foo@docker", "bar@file"],
|
||||||
"name": "middleware00@docker",
|
"name": "middleware00@docker",
|
||||||
@@ -144,7 +162,10 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"basicAuth": {
|
"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",
|
"usersFile": "/etc/foo/my/file/path/.htpasswd",
|
||||||
"realm": "Hello you are here",
|
"realm": "Hello you are here",
|
||||||
"removeHeader": true,
|
"removeHeader": true,
|
||||||
|
|||||||
@@ -4,10 +4,7 @@
|
|||||||
"rule": "Host(`jaeger-v2-example-beta1`)",
|
"rule": "Host(`jaeger-v2-example-beta1`)",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "jaeger_v2-example-beta1@docker",
|
"name": "jaeger_v2-example-beta1@docker",
|
||||||
"using": [
|
"using": ["web-secured", "web"],
|
||||||
"web-secured",
|
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"priority": 10,
|
"priority": 10,
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
},
|
},
|
||||||
@@ -20,6 +17,24 @@
|
|||||||
],
|
],
|
||||||
"status": "disabled",
|
"status": "disabled",
|
||||||
"name": "orphan-router@file",
|
"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": [
|
"middlewares": [
|
||||||
"middleware00@docker",
|
"middleware00@docker",
|
||||||
"middleware01@docker",
|
"middleware01@docker",
|
||||||
@@ -43,20 +58,12 @@
|
|||||||
"middleware19@docker",
|
"middleware19@docker",
|
||||||
"middleware20@docker"
|
"middleware20@docker"
|
||||||
],
|
],
|
||||||
"using": [
|
"using": ["web-secured", "web", "traefik", "web2", "web3"],
|
||||||
"web-secured",
|
|
||||||
"web",
|
|
||||||
"traefik",
|
|
||||||
"web2",
|
|
||||||
"web3"
|
|
||||||
],
|
|
||||||
"priority": 30,
|
"priority": 30,
|
||||||
"provider": "file"
|
"provider": "file"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"entryPoints": [
|
"entryPoints": ["web-mtls"],
|
||||||
"web-mtls"
|
|
||||||
],
|
|
||||||
"service": "api3_v2-example-beta1",
|
"service": "api3_v2-example-beta1",
|
||||||
"rule": "Host(`server`) \u0026\u0026 Path(`/mtls`)",
|
"rule": "Host(`server`) \u0026\u0026 Path(`/mtls`)",
|
||||||
"tls": {
|
"tls": {
|
||||||
@@ -65,24 +72,15 @@
|
|||||||
"domains": [
|
"domains": [
|
||||||
{
|
{
|
||||||
"main": "example.com",
|
"main": "example.com",
|
||||||
"sans": [
|
"sans": ["foo.example.com", "bar.example.com"]
|
||||||
"foo.example.com",
|
|
||||||
"bar.example.com"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"main": "domain.com",
|
"main": "domain.com",
|
||||||
"sans": [
|
"sans": ["foo.domain.com", "bar.domain.com"]
|
||||||
"foo.domain.com",
|
|
||||||
"bar.domain.com"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"main": "my.domain.com",
|
"main": "my.domain.com",
|
||||||
"sans": [
|
"sans": ["foo.my.domain.com", "bar.my.domain.com"]
|
||||||
"foo.my.domain.com",
|
|
||||||
"bar.my.domain.com"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -90,39 +88,27 @@
|
|||||||
"priority": 42,
|
"priority": 42,
|
||||||
"name": "server-mtls@docker",
|
"name": "server-mtls@docker",
|
||||||
"provider": "docker",
|
"provider": "docker",
|
||||||
"using": [
|
"using": ["web-mtls"]
|
||||||
"web-mtls"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"entryPoints": [
|
"entryPoints": ["web-redirect"],
|
||||||
"web-redirect"
|
"middlewares": ["redirect@file"],
|
||||||
],
|
|
||||||
"middlewares": [
|
|
||||||
"redirect@file"
|
|
||||||
],
|
|
||||||
"service": "api2_v2-example-beta1",
|
"service": "api2_v2-example-beta1",
|
||||||
"rule": "Host(`server`)",
|
"rule": "Host(`server`)",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "server-redirect@docker",
|
"name": "server-redirect@docker",
|
||||||
"using": [
|
"using": ["web-redirect"],
|
||||||
"web-redirect"
|
|
||||||
],
|
|
||||||
"priority": 9223372036854776000,
|
"priority": 9223372036854776000,
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"entryPoints": [
|
"entryPoints": ["web-secured"],
|
||||||
"web-secured"
|
|
||||||
],
|
|
||||||
"service": "api2_v2-example-beta1",
|
"service": "api2_v2-example-beta1",
|
||||||
"rule": "Host(`server`)",
|
"rule": "Host(`server`)",
|
||||||
"tls": {},
|
"tls": {},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "server-secured@docker",
|
"name": "server-secured@docker",
|
||||||
"using": [
|
"using": ["web-secured"],
|
||||||
"web-secured"
|
|
||||||
],
|
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -130,42 +116,27 @@
|
|||||||
"rule": "Host(`traefik-v2-example-beta1`)",
|
"rule": "Host(`traefik-v2-example-beta1`)",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "traefik_v2-example-beta1@docker",
|
"name": "traefik_v2-example-beta1@docker",
|
||||||
"using": [
|
"using": ["web-secured", "web"],
|
||||||
"web-secured",
|
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"entryPoints": [
|
"entryPoints": ["web"],
|
||||||
"web"
|
"middlewares": ["add-foo"],
|
||||||
],
|
|
||||||
"middlewares": [
|
|
||||||
"add-foo"
|
|
||||||
],
|
|
||||||
"service": "api_v2-example-beta1",
|
"service": "api_v2-example-beta1",
|
||||||
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "web@docker",
|
"name": "web@docker",
|
||||||
"using": [
|
"using": ["web"],
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"entryPoints": [
|
"entryPoints": ["web"],
|
||||||
"web"
|
"middlewares": ["whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware"],
|
||||||
],
|
|
||||||
"middlewares": [
|
|
||||||
"whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware"
|
|
||||||
],
|
|
||||||
"service": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service",
|
"service": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service",
|
||||||
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
"rule": "Host(`jorge.dockeree.containous.cloud`)",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"name": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a@kubernetescrd",
|
"name": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a@kubernetescrd",
|
||||||
"using": [
|
"using": ["web"],
|
||||||
"web"
|
|
||||||
],
|
|
||||||
"provider": "docker"
|
"provider": "docker"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,10 +9,7 @@
|
|||||||
"passHostHeader": true
|
"passHostHeader": true
|
||||||
},
|
},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": [
|
"usedBy": ["server-redirect@docker", "server-secured@docker"],
|
||||||
"server-redirect@docker",
|
|
||||||
"server-secured@docker"
|
|
||||||
],
|
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
"http://10.0.1.12:80": "UP"
|
"http://10.0.1.12:80": "UP"
|
||||||
},
|
},
|
||||||
@@ -95,9 +92,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": [
|
"usedBy": ["server-mtls@docker"],
|
||||||
"server-mtls@docker"
|
|
||||||
],
|
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
"http://10.0.1.20:80": "UP",
|
"http://10.0.1.20:80": "UP",
|
||||||
"http://10.0.1.21:80": "UP",
|
"http://10.0.1.21:80": "UP",
|
||||||
@@ -107,8 +102,25 @@
|
|||||||
"http://10.0.1.25:80": "UP"
|
"http://10.0.1.25:80": "UP"
|
||||||
},
|
},
|
||||||
"name": "api3_v2-example-beta1@docker",
|
"name": "api3_v2-example-beta1@docker",
|
||||||
"type": "loadbalancer",
|
"type": "mirroring",
|
||||||
"provider": "docker"
|
"provider": "docker",
|
||||||
|
"mirroring": {
|
||||||
|
"mirrors": [
|
||||||
|
{
|
||||||
|
"name": "two@docker",
|
||||||
|
"percent": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "three@docker",
|
||||||
|
"percent": 15
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "four@docker",
|
||||||
|
"percent": 80
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"service": "one@docker"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"loadBalancer": {
|
"loadBalancer": {
|
||||||
@@ -120,9 +132,7 @@
|
|||||||
"passHostHeader": true
|
"passHostHeader": true
|
||||||
},
|
},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": [
|
"usedBy": ["web@docker"],
|
||||||
"web@docker"
|
|
||||||
],
|
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
"http://10.0.1.11:80": "UP"
|
"http://10.0.1.11:80": "UP"
|
||||||
},
|
},
|
||||||
@@ -140,9 +150,7 @@
|
|||||||
"passHostHeader": true
|
"passHostHeader": true
|
||||||
},
|
},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": [
|
"usedBy": ["jaeger_v2-example-beta1@docker"],
|
||||||
"jaeger_v2-example-beta1@docker"
|
|
||||||
],
|
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
"http://10.0.1.20:5775": "UP"
|
"http://10.0.1.20:5775": "UP"
|
||||||
},
|
},
|
||||||
@@ -174,9 +182,7 @@
|
|||||||
"passHostHeader": true
|
"passHostHeader": true
|
||||||
},
|
},
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"usedBy": [
|
"usedBy": ["traefik_v2-example-beta1@docker"],
|
||||||
"traefik_v2-example-beta1@docker"
|
|
||||||
],
|
|
||||||
"serverStatus": {
|
"serverStatus": {
|
||||||
"http://10.0.1.10:80": "UP"
|
"http://10.0.1.10:80": "UP"
|
||||||
},
|
},
|
||||||
@@ -189,9 +195,7 @@
|
|||||||
"provider": "docker",
|
"provider": "docker",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"type": "weighted",
|
"type": "weighted",
|
||||||
"usedBy": [
|
"usedBy": ["foo@docker"],
|
||||||
"foo@docker"
|
|
||||||
],
|
|
||||||
"weighted": {
|
"weighted": {
|
||||||
"sticky": {
|
"sticky": {
|
||||||
"cookie": {
|
"cookie": {
|
||||||
@@ -207,9 +211,7 @@
|
|||||||
"provider": "docker",
|
"provider": "docker",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"type": "weighted",
|
"type": "weighted",
|
||||||
"usedBy": [
|
"usedBy": ["fii@docker"],
|
||||||
"fii@docker"
|
|
||||||
],
|
|
||||||
"weighted": {
|
"weighted": {
|
||||||
"sticky": {
|
"sticky": {
|
||||||
"cookie": {}
|
"cookie": {}
|
||||||
@@ -238,8 +240,6 @@
|
|||||||
"provider": "docker",
|
"provider": "docker",
|
||||||
"status": "enabled",
|
"status": "enabled",
|
||||||
"type": "mirroring",
|
"type": "mirroring",
|
||||||
"usedBy": [
|
"usedBy": ["foo@docker"]
|
||||||
"foo@docker"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"Version": "3.4.0",
|
"Version": "3.6.0",
|
||||||
"Codename": "montdor",
|
"Codename": "ramequin",
|
||||||
"disableDashboardAd": false,
|
"disableDashboardAd": false,
|
||||||
"startDate": "2025-03-28T14:58:25.8937758+01:00"
|
"startDate": "2025-03-28T14:58:25.8937758+01:00"
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { HttpMiddlewareRender } from './HttpMiddleware'
|
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<HttpMiddlewarePage />', () => {
|
describe('<HttpMiddlewarePage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -55,7 +53,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/middlewares/middleware-simple', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,12 +65,11 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
expect(middlewareCard.innerHTML).toContain('addprefix')
|
expect(middlewareCard.innerHTML).toContain('addprefix')
|
||||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||||
expect(middlewareCard.innerHTML).toContain('Success')
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
expect(middlewareCard.innerHTML).toContain('/foo')
|
expect(container.innerHTML).toContain('/foo')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test-simple@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test-simple@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render a plugin middleware', () => {
|
it('should render a plugin middleware', () => {
|
||||||
@@ -102,7 +99,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/middlewares/middleware-plugin', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,11 +109,11 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const middlewareCard = getByTestId('middleware-card')
|
const middlewareCard = getByTestId('middleware-card')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth')
|
expect(middlewareCard.innerHTML).toContain('jwtAuth')
|
||||||
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test-plugin@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test-plugin@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render a complex middleware', async () => {
|
it('should render a complex middleware', async () => {
|
||||||
@@ -342,7 +339,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/middlewares/middleware-complex', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -353,60 +350,50 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
const middlewareCard = getByTestId('middleware-card')
|
const middlewareCard = getByTestId('middleware-card')
|
||||||
expect(middlewareCard.innerHTML).toContain('Success')
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
expect(middlewareCard.innerHTML).toContain('the-provider')
|
expect(middlewareCard.innerHTML).toContain('the-provider')
|
||||||
expect(middlewareCard.innerHTML).toContain('redirect-scheme')
|
expect(container.innerHTML).toContain('redirect-scheme')
|
||||||
expect(middlewareCard.innerHTML).toContain('add-prefix-sample')
|
expect(container.innerHTML).toContain('add-prefix-sample')
|
||||||
expect(middlewareCard.innerHTML).toContain('buffer-retry-expression')
|
expect(container.innerHTML).toContain('buffer-retry-expression')
|
||||||
expect(middlewareCard.innerHTML).toContain('circuit-breaker')
|
expect(container.innerHTML).toContain('circuit-breaker')
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement'])
|
expect(container.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to'])
|
expect(container.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header'])
|
expect(container.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(container.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(container.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(container.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(container.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2'])
|
expect(container.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2'])
|
expect(container.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2'])
|
||||||
expect(middlewareCard.innerHTML).toContain('allowed.origin')
|
expect(container.innerHTML).toContain('allowed.origin')
|
||||||
expect(middlewareCard.innerHTML).toContain('custom-frame-options')
|
expect(container.innerHTML).toContain('custom-frame-options')
|
||||||
expect(middlewareCard.innerHTML).toContain('content-security-policy')
|
expect(container.innerHTML).toContain('content-security-policy')
|
||||||
expect(middlewareCard.innerHTML).toContain('public-key')
|
expect(container.innerHTML).toContain('public-key')
|
||||||
expect(middlewareCard.innerHTML).toContain('referrer-policy')
|
expect(container.innerHTML).toContain('referrer-policy')
|
||||||
expect(middlewareCard.innerHTML).toContain('feature-policy')
|
expect(container.innerHTML).toContain('feature-policy')
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT'])
|
expect(container.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2'])
|
expect(container.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b'])
|
expect(container.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b'])
|
expect(container.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b'])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
expect(container.innerHTML).toIncludeMultiple([
|
||||||
'forward-auth-address',
|
'forward-auth-address',
|
||||||
'auth-response-header-1',
|
'auth-response-header-1',
|
||||||
'auth-response-header-2',
|
'auth-response-header-2',
|
||||||
])
|
])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
expect(container.innerHTML).toIncludeMultiple([
|
||||||
'error-sample',
|
'error-sample',
|
||||||
'status-1',
|
'status-1',
|
||||||
'status-2',
|
'status-2',
|
||||||
'errors-service',
|
'errors-service',
|
||||||
'errors-query',
|
'errors-query',
|
||||||
])
|
])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
expect(container.innerHTML).toIncludeMultiple(['chain-middleware-1', 'chain-middleware-2', 'chain-middleware-3'])
|
||||||
'chain-middleware-1',
|
expect(container.innerHTML).toIncludeMultiple(['user1', 'user2', 'users/file', 'realm-sample', 'basic-auth-header'])
|
||||||
'chain-middleware-2',
|
expect(container.innerHTML).toIncludeMultiple([
|
||||||
'chain-middleware-3',
|
|
||||||
])
|
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
|
||||||
'user1',
|
|
||||||
'user2',
|
|
||||||
'users/file',
|
|
||||||
'realm-sample',
|
|
||||||
'basic-auth-header',
|
|
||||||
])
|
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
|
||||||
'strip-prefix1',
|
'strip-prefix1',
|
||||||
'strip-prefix2',
|
'strip-prefix2',
|
||||||
'strip-prefix-regex1',
|
'strip-prefix-regex1',
|
||||||
'strip-prefix-regex2',
|
'strip-prefix-regex2',
|
||||||
])
|
])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
expect(container.innerHTML).toIncludeMultiple([
|
||||||
'10000',
|
'10000',
|
||||||
'10001',
|
'10001',
|
||||||
'10002',
|
'10002',
|
||||||
@@ -421,7 +408,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
'10011',
|
'10011',
|
||||||
'10012',
|
'10012',
|
||||||
])
|
])
|
||||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
expect(container.innerHTML).toIncludeMultiple([
|
||||||
'plugin-ldap-source',
|
'plugin-ldap-source',
|
||||||
'plugin-ldap-base-dn',
|
'plugin-ldap-base-dn',
|
||||||
'plugin-ldap-attribute',
|
'plugin-ldap-attribute',
|
||||||
@@ -438,9 +425,8 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test-complex@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test-complex@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render a plugin middleware with no type', async () => {
|
it('should render a plugin middleware with no type', async () => {
|
||||||
@@ -464,7 +450,7 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/middlewares/middleware-plugin-no-type', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -474,15 +460,15 @@ describe('<HttpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const middlewareCard = getByTestId('middleware-card')
|
const middlewareCard = getByTestId('middleware-card')
|
||||||
expect(middlewareCard.innerHTML).toContain('Success')
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > child')
|
expect(container.innerHTML).toContain('jwtAuth > child')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > negative Grand Child')
|
expect(container.innerHTML).toContain('jwtAuth > sibling > negative Grand Child')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > positive Grand Child')
|
expect(container.innerHTML).toContain('jwtAuth > sibling > positive Grand Child')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > string Child')
|
expect(container.innerHTML).toContain('jwtAuth > string Child')
|
||||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > array Child')
|
expect(container.innerHTML).toContain('jwtAuth > array Child')
|
||||||
|
|
||||||
const childSpans = Array.from(middlewareCard.querySelectorAll('span')).filter((span) =>
|
const childSpans = Array.from(container.querySelectorAll('span')).filter((span) =>
|
||||||
['0', '1', '2', '3', '123'].includes(span.innerHTML),
|
['0', '1', '2', '3', '123'].includes(span.innerHTML),
|
||||||
)
|
)
|
||||||
expect(childSpans.length).toBe(7)
|
expect(childSpans.length).toBe(6)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,82 +1,12 @@
|
|||||||
import { Box, Card, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||||
import { RenderMiddleware } from 'components/resources/MiddlewarePanel'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HttpMiddleware = () => {
|
export const HttpMiddleware = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'middlewares')
|
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
|
export default HttpMiddleware
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
return (
|
return (
|
||||||
<ClickableRow key={row.name} to={`/http/middlewares/${row.name}`}>
|
<ClickableRow key={row.name} to={`/http/middlewares/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.name} />
|
<TooltipText text={row.name} />
|
||||||
@@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<TooltipText text={middlewareType} />
|
<TooltipText text={middlewareType} />
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</ClickableRow>
|
</ClickableRow>
|
||||||
)
|
)
|
||||||
@@ -69,7 +60,7 @@ export const HttpMiddlewaresRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { HttpRouterRender } from './HttpRouter'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import apiEntrypoints from 'mocks/data/api-entrypoints.json'
|
import apiEntrypoints from 'mocks/data/api-entrypoints.json'
|
||||||
import apiHttpMiddlewares from 'mocks/data/api-http_middlewares.json'
|
import apiHttpMiddlewares from 'mocks/data/api-http_middlewares.json'
|
||||||
import apiHttpRouters from 'mocks/data/api-http_routers.json'
|
import apiHttpRouters from 'mocks/data/api-http_routers.json'
|
||||||
@@ -9,7 +7,7 @@ import { renderWithProviders } from 'utils/test'
|
|||||||
describe('<HttpRouterPage />', () => {
|
describe('<HttpRouterPage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -17,7 +15,7 @@ describe('<HttpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -25,7 +23,7 @@ describe('<HttpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -42,7 +40,7 @@ describe('<HttpRouterPage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/routers/orphan-router@file', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,7 +50,6 @@ describe('<HttpRouterPage />', () => {
|
|||||||
expect(routerStructure.innerHTML).toContain(':8080')
|
expect(routerStructure.innerHTML).toContain(':8080')
|
||||||
expect(routerStructure.innerHTML).toContain(':8002')
|
expect(routerStructure.innerHTML).toContain(':8002')
|
||||||
expect(routerStructure.innerHTML).toContain(':8003')
|
expect(routerStructure.innerHTML).toContain(':8003')
|
||||||
expect(routerStructure.innerHTML).toContain('orphan-router@file')
|
|
||||||
expect(routerStructure.innerHTML).toContain('middleware00')
|
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||||
expect(routerStructure.innerHTML).toContain('middleware01')
|
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||||
expect(routerStructure.innerHTML).toContain('middleware02')
|
expect(routerStructure.innerHTML).toContain('middleware02')
|
||||||
@@ -78,43 +75,35 @@ describe('<HttpRouterPage />', () => {
|
|||||||
expect(routerStructure.innerHTML).toContain('HTTP Router')
|
expect(routerStructure.innerHTML).toContain('HTTP Router')
|
||||||
expect(routerStructure.innerHTML).not.toContain('TCP 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(routerDetailsSection?.innerHTML).toContain('Error')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('orphan-router@file')
|
expect(routerDetailsSection?.querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Error')
|
expect(routerDetailsSection?.innerHTML).toContain(
|
||||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="file"]')).toBeTruthy()
|
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain(
|
|
||||||
'Path(`somethingreallyunexpectedbutalsoverylongitgetsoutofthecontainermaybe`)',
|
'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)')
|
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||||
const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || [])
|
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware00')
|
expect(routerStructure.innerHTML).toContain('middleware02')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware01')
|
expect(routerStructure.innerHTML).toContain('middleware03')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware02')
|
expect(routerStructure.innerHTML).toContain('middleware04')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware03')
|
expect(routerStructure.innerHTML).toContain('middleware05')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware04')
|
expect(routerStructure.innerHTML).toContain('middleware06')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware05')
|
expect(routerStructure.innerHTML).toContain('middleware07')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware06')
|
expect(routerStructure.innerHTML).toContain('middleware08')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware07')
|
expect(routerStructure.innerHTML).toContain('middleware09')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware08')
|
expect(routerStructure.innerHTML).toContain('middleware10')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware09')
|
expect(routerStructure.innerHTML).toContain('middleware11')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware10')
|
expect(routerStructure.innerHTML).toContain('middleware12')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware11')
|
expect(routerStructure.innerHTML).toContain('middleware13')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware12')
|
expect(routerStructure.innerHTML).toContain('middleware14')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware13')
|
expect(routerStructure.innerHTML).toContain('middleware15')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware14')
|
expect(routerStructure.innerHTML).toContain('middleware16')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware15')
|
expect(routerStructure.innerHTML).toContain('middleware17')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware16')
|
expect(routerStructure.innerHTML).toContain('middleware18')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware17')
|
expect(routerStructure.innerHTML).toContain('middleware19')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware18')
|
expect(routerStructure.innerHTML).toContain('middleware20')
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware19')
|
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('middleware20')
|
|
||||||
expect(middlewaresPanel?.innerHTML).toContain('Success')
|
|
||||||
expect(providers.length).toBe(21)
|
|
||||||
|
|
||||||
expect(getByTestId('/http/middlewares/middleware00@docker')).toBeInTheDocument()
|
expect(getByTestId('/http/middlewares/middleware00@docker')).toBeInTheDocument()
|
||||||
|
|
||||||
|
|||||||
@@ -1,161 +1,13 @@
|
|||||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { useContext, useEffect, useMemo } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
import MiddlewarePanel from 'components/resources/MiddlewarePanel'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HttpRouter = () => {
|
export const HttpRouter = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'routers')
|
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
|
export default HttpRouter
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { FiShield } from 'react-icons/fi'
|
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { Chips } from 'components/resources/DetailSections'
|
import { Chips } from 'components/resources/DetailItemComponents'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
import TlsIcon from 'components/routers/TlsIcon'
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import Tooltip from 'components/Tooltip'
|
import Tooltip from 'components/Tooltip'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
@@ -22,25 +22,32 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => {
|
|||||||
const HttpRoutersRenderRow = (row) => (
|
const HttpRoutersRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/${protocol}/routers/${row.name}`}>
|
<ClickableRow key={row.name} to={`/${protocol}/routers/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
{protocol !== 'udp' && (
|
{protocol !== 'udp' && (
|
||||||
<>
|
<>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
{row.tls && (
|
{row.tls && (
|
||||||
<Tooltip label="TLS ON">
|
<Tooltip label="TLS ON">
|
||||||
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
<Box css={{ width: 20, height: 20 }} data-testid="tls-on">
|
||||||
<FiShield color="#008000" fill="#008000" size={24} />
|
<TlsIcon />
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<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>
|
</AriaTd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -52,11 +59,7 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => {
|
|||||||
<TooltipText text={row.service} isTruncated />
|
<TooltipText text={row.service} isTruncated />
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.priority} isTruncated />
|
<TooltipText text={row.priority} isTruncated />
|
||||||
@@ -86,8 +89,8 @@ export const HttpRoutersRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="TLS" css={{ width: '24px' }} />
|
||||||
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
||||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { HttpServiceRender } from './HttpService'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<HttpServicePage />', () => {
|
describe('<HttpServicePage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -73,7 +71,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -88,7 +86,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
expect(serviceDetails.innerHTML).toContain('docker')
|
expect(serviceDetails.innerHTML).toContain('docker')
|
||||||
expect(serviceDetails.innerHTML).toContain('Status')
|
expect(serviceDetails.innerHTML).toContain('Status')
|
||||||
expect(serviceDetails.innerHTML).toContain('Success')
|
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('True')
|
||||||
|
|
||||||
const serversList = getByTestId('servers-list')
|
const serversList = getByTestId('servers-list')
|
||||||
@@ -96,10 +94,9 @@ describe('<HttpServicePage />', () => {
|
|||||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
getByTestId('health-check')
|
getByTestId('health-check')
|
||||||
@@ -145,7 +142,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -200,7 +197,7 @@ describe('<HttpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/http/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,323 +1,13 @@
|
|||||||
import { Badge, Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
import {
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HttpService = () => {
|
export const HttpService = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'services')
|
const { data, error } = useResourceDetail(name ?? '', 'services')
|
||||||
return <HttpServiceRender data={data} error={error} name={name!} />
|
|
||||||
|
return <ServiceDetail data={data} error={error} name={name!} protocol="http" />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HttpService
|
export default HttpService
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
const HttpServicesRenderRow = (row) => (
|
const HttpServicesRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/http/services/${row.name}`}>
|
<ClickableRow key={row.name} to={`/http/services/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.name} />
|
<TooltipText text={row.name} />
|
||||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</ClickableRow>
|
</ClickableRow>
|
||||||
)
|
)
|
||||||
@@ -67,7 +58,7 @@ export const HttpServicesRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { TcpMiddlewareRender } from './TcpMiddleware'
|
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<TcpMiddlewarePage />', () => {
|
describe('<TcpMiddlewarePage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -55,7 +53,7 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/tcp/middlewares/middleware-simple', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,14 +64,13 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
const middlewareCard = getByTestId('middleware-card')
|
const middlewareCard = getByTestId('middleware-card')
|
||||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||||
expect(middlewareCard.innerHTML).toContain('Success')
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
expect(container.innerHTML).toContain('inFlightConn')
|
||||||
expect(middlewareCard.innerHTML).toContain('amount')
|
expect(container.innerHTML).toContain('amount')
|
||||||
expect(middlewareCard.innerHTML).toContain('10')
|
expect(container.innerHTML).toContain('10')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test-simple@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test-simple@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render a complex middleware', async () => {
|
it('should render a complex middleware', async () => {
|
||||||
@@ -106,7 +103,7 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/tcp/middlewares/middleware-complex', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -117,17 +114,16 @@ describe('<TcpMiddlewarePage />', () => {
|
|||||||
const middlewareCard = getByTestId('middleware-card')
|
const middlewareCard = getByTestId('middleware-card')
|
||||||
expect(middlewareCard.innerHTML).toContain('Success')
|
expect(middlewareCard.innerHTML).toContain('Success')
|
||||||
expect(middlewareCard.innerHTML).toContain('the-provider')
|
expect(middlewareCard.innerHTML).toContain('the-provider')
|
||||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
expect(container.innerHTML).toContain('inFlightConn')
|
||||||
expect(middlewareCard.innerHTML).toContain('amount')
|
expect(container.innerHTML).toContain('amount')
|
||||||
expect(middlewareCard.innerHTML).toContain('10')
|
expect(container.innerHTML).toContain('10')
|
||||||
expect(middlewareCard.innerHTML).toContain('ipWhiteList')
|
expect(container.innerHTML).toContain('ipWhiteList')
|
||||||
expect(middlewareCard.innerHTML).toContain('source Range')
|
expect(container.innerHTML).toContain('source Range')
|
||||||
expect(middlewareCard.innerHTML).toContain('125.0.0.1')
|
expect(container.innerHTML).toContain('125.0.0.1')
|
||||||
expect(middlewareCard.innerHTML).toContain('125.0.0.4')
|
expect(container.innerHTML).toContain('125.0.0.4')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test-complex@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test-complex@docker')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,82 +1,12 @@
|
|||||||
import { Card, Box, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail'
|
||||||
import { RenderMiddleware } from 'components/resources/MiddlewarePanel'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TcpMiddleware = () => {
|
export const TcpMiddleware = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'middlewares', 'tcp')
|
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
|
export default TcpMiddleware
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
return (
|
return (
|
||||||
<ClickableRow key={row.name} to={`/tcp/middlewares/${row.name}`}>
|
<ClickableRow key={row.name} to={`/tcp/middlewares/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.name} />
|
<TooltipText text={row.name} />
|
||||||
@@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<TooltipText text={middlewareType} />
|
<TooltipText text={middlewareType} />
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</ClickableRow>
|
</ClickableRow>
|
||||||
)
|
)
|
||||||
@@ -69,7 +60,7 @@ export const TcpMiddlewaresRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { TcpRouterRender } from './TcpRouter'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<TcpRouterPage />', () => {
|
describe('<TcpRouterPage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<TcpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<TcpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -68,38 +66,24 @@ describe('<TcpRouterPage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/tcp/routers/tcp-all@docker', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const routerStructure = getByTestId('router-structure')
|
const routerStructure = getByTestId('router-structure')
|
||||||
expect(routerStructure.innerHTML).toContain(':443')
|
expect(routerStructure.innerHTML).toContain(':443')
|
||||||
expect(routerStructure.innerHTML).toContain(':8000')
|
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).toContain('TCP Router')
|
||||||
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
||||||
|
|
||||||
const routerDetailsSection = getByTestId('router-details')
|
const routerDetailsSection = getByTestId('router-details')
|
||||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
|
||||||
|
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Status')
|
expect(routerDetailsSection?.innerHTML).toContain('Status')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Success')
|
expect(routerDetailsSection?.innerHTML).toContain('Success')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Provider')
|
expect(routerDetailsSection?.innerHTML).toContain('Provider')
|
||||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(routerDetailsSection?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Name')
|
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('tcp-all@docker')
|
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||||
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(getByTestId('/tcp/services/tcp-all@docker')).toBeInTheDocument()
|
expect(getByTestId('/tcp/services/tcp-all@docker')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,91 +1,13 @@
|
|||||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
import MiddlewarePanel from 'components/resources/MiddlewarePanel'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TcpRouter = () => {
|
export const TcpRouter = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'routers', 'tcp')
|
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
|
export default TcpRouter
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import { FiShield } from 'react-icons/fi'
|
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { Chips } from 'components/resources/DetailSections'
|
import { Chips } from 'components/resources/DetailItemComponents'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
import TlsIcon from 'components/routers/TlsIcon'
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import Tooltip from 'components/Tooltip'
|
import Tooltip from 'components/Tooltip'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
@@ -22,17 +22,13 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
const TcpRoutersRenderRow = (row) => (
|
const TcpRoutersRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/tcp/routers/${row.name}`}>
|
<ClickableRow key={row.name} to={`/tcp/routers/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
{row.tls && (
|
{row.tls && (
|
||||||
<Tooltip label="TLS ON">
|
<Tooltip label="TLS ON">
|
||||||
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
||||||
<FiShield color="#008000" fill="#008000" size={24} />
|
<TlsIcon />
|
||||||
</Box>
|
</Box>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -48,11 +44,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<TooltipText text={row.service} isTruncated />
|
<TooltipText text={row.service} isTruncated />
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.priority} isTruncated />
|
<TooltipText text={row.priority} isTruncated />
|
||||||
@@ -82,7 +74,7 @@ export const TcpRoutersRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="TLS" css={{ width: '40px' }} />
|
||||||
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
||||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { TcpServiceRender } from './TcpService'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<TcpServicePage />', () => {
|
describe('<TcpServicePage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/tcp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -71,7 +69,7 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/tcp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,38 +77,37 @@ describe('<TcpServicePage />', () => {
|
|||||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'service-test1')
|
const titleTags = headings.filter((h1) => h1.innerHTML === 'service-test1')
|
||||||
expect(titleTags.length).toBe(1)
|
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('Type')
|
||||||
expect(serviceDetails.innerHTML).toContain('loadbalancer')
|
expect(serviceDetails.innerHTML).toContain('loadbalancer')
|
||||||
expect(serviceDetails.innerHTML).toContain('Provider')
|
expect(serviceDetails.innerHTML).toContain('Provider')
|
||||||
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||||
expect(serviceDetails.innerHTML).toContain('Status')
|
expect(serviceDetails.innerHTML).toContain('Status')
|
||||||
expect(serviceDetails.innerHTML).toContain('Success')
|
expect(serviceDetails.innerHTML).toContain('Success')
|
||||||
expect(serviceDetails.innerHTML).toContain('Termination Delay')
|
expect(serviceDetails.innerHTML).toContain('Termination delay')
|
||||||
expect(serviceDetails.innerHTML).toContain('10 ms')
|
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('Interval')
|
||||||
expect(healthCheck.innerHTML).toContain('30s')
|
expect(healthCheck.innerHTML).toContain('30s')
|
||||||
expect(healthCheck.innerHTML).toContain('Timeout')
|
expect(healthCheck.innerHTML).toContain('Timeout')
|
||||||
expect(healthCheck.innerHTML).toContain('10s')
|
expect(healthCheck.innerHTML).toContain('10s')
|
||||||
expect(healthCheck.innerHTML).toContain('Port')
|
expect(healthCheck.innerHTML).toContain('Port')
|
||||||
expect(healthCheck.innerHTML).toContain('8080')
|
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('1m')
|
||||||
expect(healthCheck.innerHTML).toContain('Send')
|
expect(healthCheck.innerHTML).toContain('Send')
|
||||||
expect(healthCheck.innerHTML).toContain('PING')
|
expect(healthCheck.innerHTML).toContain('PING')
|
||||||
expect(healthCheck.innerHTML).toContain('Expect')
|
expect(healthCheck.innerHTML).toContain('Expect')
|
||||||
expect(healthCheck.innerHTML).toContain('PONG')
|
expect(healthCheck.innerHTML).toContain('PONG')
|
||||||
|
|
||||||
const serversList = getByTestId('tcp-servers-list')
|
const serversList = getByTestId('servers-list')
|
||||||
expect(serversList.childNodes.length).toBe(1)
|
expect(serversList.childNodes.length).toBe(1)
|
||||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the service servers from the serverStatus property', async () => {
|
it('should render the service servers from the serverStatus property', async () => {
|
||||||
@@ -153,19 +150,18 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ 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.childNodes.length).toBe(1)
|
||||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render used by routers table if the usedBy property is empty', async () => {
|
it('should not render used by routers table if the usedBy property is empty', async () => {
|
||||||
@@ -180,7 +176,7 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/tcp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,14 +219,14 @@ describe('<TcpServicePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 headings = Array.from(container.getElementsByTagName('h1'))
|
||||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'weighted-service-test')
|
const titleTags = headings.filter((h1) => h1.innerHTML === 'weighted-service-test')
|
||||||
expect(titleTags.length).toBe(1)
|
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('Type')
|
||||||
expect(serviceDetails.innerHTML).toContain('weighted')
|
expect(serviceDetails.innerHTML).toContain('weighted')
|
||||||
expect(serviceDetails.innerHTML).toContain('Provider')
|
expect(serviceDetails.innerHTML).toContain('Provider')
|
||||||
@@ -238,7 +234,7 @@ describe('<TcpServicePage />', () => {
|
|||||||
expect(serviceDetails.innerHTML).toContain('Status')
|
expect(serviceDetails.innerHTML).toContain('Status')
|
||||||
expect(serviceDetails.innerHTML).toContain('Success')
|
expect(serviceDetails.innerHTML).toContain('Success')
|
||||||
|
|
||||||
const weightedServices = getByTestId('tcp-weighted-services')
|
const weightedServices = getByTestId('weighted-services')
|
||||||
expect(weightedServices.childNodes.length).toBe(2)
|
expect(weightedServices.childNodes.length).toBe(2)
|
||||||
expect(weightedServices.innerHTML).toContain('service1@docker')
|
expect(weightedServices.innerHTML).toContain('service1@docker')
|
||||||
expect(weightedServices.innerHTML).toContain('80')
|
expect(weightedServices.innerHTML).toContain('80')
|
||||||
|
|||||||
@@ -1,291 +1,13 @@
|
|||||||
import { Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
import {
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TcpService = () => {
|
export const TcpService = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'services', 'tcp')
|
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
|
export default TcpService
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
const TcpServicesRenderRow = (row) => (
|
const TcpServicesRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/tcp/services/${row.name}`}>
|
<ClickableRow key={row.name} to={`/tcp/services/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.name} />
|
<TooltipText text={row.name} />
|
||||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</ClickableRow>
|
</ClickableRow>
|
||||||
)
|
)
|
||||||
@@ -67,7 +58,7 @@ export const TcpServicesRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { UdpRouterRender } from './UdpRouter'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<UdpRouterPage />', () => {
|
describe('<UdpRouterPage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<UdpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<UdpRouterPage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/routers/mock-router', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -53,31 +51,22 @@ describe('<UdpRouterPage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/udp/routers/udp-all@docker', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
const routerStructure = getByTestId('router-structure')
|
const routerStructure = getByTestId('router-structure')
|
||||||
expect(routerStructure.innerHTML).toContain(':443')
|
expect(routerStructure.innerHTML).toContain(':443')
|
||||||
expect(routerStructure.innerHTML).toContain(':8000')
|
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).toContain('UDP Router')
|
||||||
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
||||||
|
|
||||||
const routerDetailsSection = getByTestId('router-details')
|
const routerDetailsSection = getByTestId('router-details')
|
||||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
|
||||||
|
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Status')
|
expect(routerDetailsSection?.innerHTML).toContain('Status')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Success')
|
expect(routerDetailsSection?.innerHTML).toContain('Success')
|
||||||
expect(routerDetailsPanel?.innerHTML).toContain('Provider')
|
expect(routerDetailsSection?.innerHTML).toContain('Provider')
|
||||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(routerDetailsSection?.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(getByTestId('/udp/services/udp-all@docker')).toBeInTheDocument()
|
expect(getByTestId('/udp/services/udp-all@docker')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,88 +1,13 @@
|
|||||||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { RouterDetail } from 'components/routers/RouterDetail'
|
||||||
import RouterPanel from 'components/resources/RouterPanel'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UdpRouter = () => {
|
export const UdpRouter = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'routers', 'udp')
|
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
|
export default UdpRouter
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { Chips } from 'components/resources/DetailSections'
|
import { Chips } from 'components/resources/DetailItemComponents'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -21,11 +20,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
const UdpRoutersRenderRow = (row) => (
|
const UdpRoutersRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/udp/routers/${row.name}`}>
|
<ClickableRow key={row.name} to={`/udp/routers/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>{row.entryPoints && row.entryPoints.length > 0 && <Chips items={row.entryPoints} />}</AriaTd>
|
<AriaTd>{row.entryPoints && row.entryPoints.length > 0 && <Chips items={row.entryPoints} />}</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
@@ -35,11 +30,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<TooltipText text={row.service} isTruncated />
|
<TooltipText text={row.service} isTruncated />
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.priority} isTruncated />
|
<TooltipText text={row.priority} isTruncated />
|
||||||
@@ -69,7 +60,7 @@ export const UdpRoutersRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Service" isSortable sortByValue="service" />
|
<SortableTh label="Service" isSortable sortByValue="service" />
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { UdpServiceRender } from './UdpService'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
|
|
||||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
|
||||||
import { renderWithProviders } from 'utils/test'
|
import { renderWithProviders } from 'utils/test'
|
||||||
|
|
||||||
describe('<UdpServicePage />', () => {
|
describe('<UdpServicePage />', () => {
|
||||||
it('should render the error message', () => {
|
it('should render the error message', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||||
@@ -14,7 +12,7 @@ describe('<UdpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the skeleton', () => {
|
it('should render the skeleton', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||||
@@ -22,7 +20,7 @@ describe('<UdpServicePage />', () => {
|
|||||||
|
|
||||||
it('should render the not found page', () => {
|
it('should render the not found page', () => {
|
||||||
const { getByTestId } = renderWithProviders(
|
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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||||
@@ -61,7 +59,7 @@ describe('<UdpServicePage />', () => {
|
|||||||
|
|
||||||
const { container, getByTestId } = renderWithProviders(
|
const { container, getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,9 +74,9 @@ describe('<UdpServicePage />', () => {
|
|||||||
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||||
expect(serviceDetails.innerHTML).toContain('Status')
|
expect(serviceDetails.innerHTML).toContain('Status')
|
||||||
expect(serviceDetails.innerHTML).toContain('Success')
|
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('True')
|
||||||
expect(serviceDetails.innerHTML).toContain('Termination Delay')
|
expect(serviceDetails.innerHTML).toContain('Termination delay')
|
||||||
expect(serviceDetails.innerHTML).toContain('10 ms')
|
expect(serviceDetails.innerHTML).toContain('10 ms')
|
||||||
|
|
||||||
const serversList = getByTestId('servers-list')
|
const serversList = getByTestId('servers-list')
|
||||||
@@ -86,9 +84,8 @@ describe('<UdpServicePage />', () => {
|
|||||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the service servers from the serverStatus property', async () => {
|
it('should render the service servers from the serverStatus property', async () => {
|
||||||
@@ -131,7 +128,7 @@ describe('<UdpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,10 +137,9 @@ describe('<UdpServicePage />', () => {
|
|||||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
||||||
|
|
||||||
const routersTable = getByTestId('routers-table')
|
const routersTable = getByTestId('routers-table')
|
||||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
expect(routersTable.innerHTML).toContain('router-test1@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
expect(routersTable.innerHTML).toContain('router-test2@docker')
|
||||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render used by routers table if the usedBy property is empty', async () => {
|
it('should not render used by routers table if the usedBy property is empty', async () => {
|
||||||
@@ -158,7 +154,7 @@ describe('<UdpServicePage />', () => {
|
|||||||
|
|
||||||
const { getByTestId } = renderWithProviders(
|
const { getByTestId } = renderWithProviders(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 },
|
{ route: '/udp/services/mock-service', withPage: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,13 @@
|
|||||||
import { Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
|
||||||
import { Helmet } from 'react-helmet-async'
|
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
|
|
||||||
import { DetailSectionSkeleton } from 'components/resources/DetailSections'
|
import { ServiceDetail } from 'components/services/ServiceDetail'
|
||||||
import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection'
|
import { useResourceDetail } from 'hooks/use-resource-detail'
|
||||||
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" />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UdpService = () => {
|
export const UdpService = () => {
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data, error } = useResourceDetail(name!, 'services', 'udp')
|
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
|
export default UdpService
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Helmet } from 'react-helmet-async'
|
import { Helmet } from 'react-helmet-async'
|
||||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
|
|
||||||
import ClickableRow from 'components/ClickableRow'
|
import { ScrollTopButton } from 'components/buttons/ScrollTopButton'
|
||||||
import ProviderIcon from 'components/icons/providers'
|
import { ProviderIconWithTooltip } from 'components/icons/providers'
|
||||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
|
||||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
import ClickableRow from 'components/tables/ClickableRow'
|
||||||
import SortableTh from 'components/tables/SortableTh'
|
import SortableTh from 'components/tables/SortableTh'
|
||||||
import Tooltip from 'components/Tooltip'
|
import { searchParamsToState, TableFilter } from 'components/tables/TableFilter'
|
||||||
import TooltipText from 'components/TooltipText'
|
import TooltipText from 'components/TooltipText'
|
||||||
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
|
||||||
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder'
|
||||||
@@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
const UdpServicesRenderRow = (row) => (
|
const UdpServicesRenderRow = (row) => (
|
||||||
<ClickableRow key={row.name} to={`/udp/services/${row.name}`}>
|
<ClickableRow key={row.name} to={`/udp/services/${row.name}`}>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.status}>
|
<ResourceStatus status={row.status} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ResourceStatus status={row.status} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<TooltipText text={row.name} />
|
<TooltipText text={row.name} />
|
||||||
@@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => {
|
|||||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
<AriaTd>
|
<AriaTd>
|
||||||
<Tooltip label={row.provider}>
|
<ProviderIconWithTooltip provider={row.provider} />
|
||||||
<Box css={{ width: '32px', height: '32px' }}>
|
|
||||||
<ProviderIcon name={row.provider} />
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</AriaTd>
|
</AriaTd>
|
||||||
</ClickableRow>
|
</ClickableRow>
|
||||||
)
|
)
|
||||||
@@ -67,7 +58,7 @@ export const UdpServicesRender = ({
|
|||||||
<AriaTable>
|
<AriaTable>
|
||||||
<AriaThead>
|
<AriaThead>
|
||||||
<AriaTr>
|
<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="Name" isSortable sortByValue="name" />
|
||||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
declare namespace Object {
|
||||||
|
type JSONObject = {
|
||||||
|
[x: string]: string | number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ValuesMapType = {
|
||||||
|
[key: string]: string | number | JSONObject
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+123
@@ -0,0 +1,123 @@
|
|||||||
|
declare namespace Resource {
|
||||||
|
type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading'
|
||||||
|
|
||||||
|
type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Entrypoint {
|
||||||
|
type Details = {
|
||||||
|
name: string
|
||||||
|
address: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Router {
|
||||||
|
type TlsDomain = {
|
||||||
|
main: string
|
||||||
|
sans: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TLS = {
|
||||||
|
options: string
|
||||||
|
certResolver: string
|
||||||
|
domains: TlsDomain[]
|
||||||
|
passthrough: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Details = {
|
||||||
|
name: string
|
||||||
|
service?: string
|
||||||
|
status: 'enabled' | 'disabled' | 'warning'
|
||||||
|
rule?: string
|
||||||
|
priority?: number
|
||||||
|
provider: string
|
||||||
|
tls?: {
|
||||||
|
options: string
|
||||||
|
certResolver: string
|
||||||
|
domains: TlsDomain[]
|
||||||
|
passthrough: boolean
|
||||||
|
}
|
||||||
|
error?: string[]
|
||||||
|
entryPoints?: string[]
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type DetailsData = Details & {
|
||||||
|
middlewares?: Middleware.Details[]
|
||||||
|
hasValidMiddlewares?: boolean
|
||||||
|
entryPointsData?: Entrypoint.Details[]
|
||||||
|
using?: string[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Service {
|
||||||
|
type WeightedService = {
|
||||||
|
name: string
|
||||||
|
weight: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mirror = {
|
||||||
|
name: string
|
||||||
|
percent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type Details = {
|
||||||
|
name: string
|
||||||
|
status: 'enabled' | 'disabled' | 'warning'
|
||||||
|
provider: string
|
||||||
|
type: string
|
||||||
|
usedBy?: string[]
|
||||||
|
routers?: Router[]
|
||||||
|
serverStatus?: {
|
||||||
|
[server: string]: string
|
||||||
|
}
|
||||||
|
mirroring?: {
|
||||||
|
service: string
|
||||||
|
mirrors?: Mirror[]
|
||||||
|
}
|
||||||
|
loadBalancer?: {
|
||||||
|
servers?: { url: string }[]
|
||||||
|
passHostHeader?: boolean
|
||||||
|
terminationDelay?: number
|
||||||
|
healthCheck?: {
|
||||||
|
scheme: string
|
||||||
|
path: string
|
||||||
|
hostname: string
|
||||||
|
headers?: {
|
||||||
|
[header: string]: string
|
||||||
|
}
|
||||||
|
port?: number
|
||||||
|
send?: string
|
||||||
|
expect?: string
|
||||||
|
interval?: string
|
||||||
|
unhealthyInterval?: string
|
||||||
|
timeout?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weighted?: {
|
||||||
|
services?: WeightedService[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare namespace Middleware {
|
||||||
|
type Props = {
|
||||||
|
[prop: string]: ValuesMapType
|
||||||
|
}
|
||||||
|
|
||||||
|
type Details = {
|
||||||
|
name: string
|
||||||
|
status: 'enabled' | 'disabled' | 'warning'
|
||||||
|
provider: string
|
||||||
|
type?: string
|
||||||
|
plugin?: Record<string, unknown>
|
||||||
|
error?: string[]
|
||||||
|
routers?: string[]
|
||||||
|
usedBy?: string[]
|
||||||
|
} & Props
|
||||||
|
|
||||||
|
type DetailsData = Details & {
|
||||||
|
routers?: Router.Details[]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user