1
0
Fork 0

Migrate Traefik Proxy dashboard UI to React

This commit is contained in:
Gina A. 2025-05-28 11:26:04 +02:00 committed by GitHub
parent 4790e4910f
commit f16fff577a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
324 changed files with 28303 additions and 19567 deletions

View file

@ -0,0 +1,53 @@
import AdditionalFeatures from './AdditionalFeatures'
import { MiddlewareProps } from 'hooks/use-resource-detail'
import { renderWithProviders } from 'utils/test'
describe('<AdditionalFeatures />', () => {
it('should render the middleware info', () => {
renderWithProviders(<AdditionalFeatures uid="test-key" />)
})
it('should render the middleware info with number', () => {
const middlewares: MiddlewareProps[] = [
{
retry: {
attempts: 2,
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('Retry: Attempts=2')
})
it('should render the middleware info with string', () => {
const middlewares: MiddlewareProps[] = [
{
circuitBreaker: {
expression: 'expression',
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('CircuitBreaker: Expression="expression"')
})
it('should render the middleware info with string', () => {
const middlewares: MiddlewareProps[] = [
{
rateLimit: {
burst: 100,
average: 100,
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('RateLimit: Burst=100, Average=100')
})
})

View file

@ -0,0 +1,73 @@
import { Badge, Box, Text } from '@traefiklabs/faency'
import Tooltip from 'components/Tooltip'
import { MiddlewareProps, ValuesMapType } from 'hooks/use-resource-detail'
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1)
}
function quote(value: string | number): string | number {
if (typeof value === 'string') {
return `"${value}"`
}
return value
}
function quoteArray(values: (string | number)[]): (string | number)[] {
return values.map(quote)
}
const renderFeatureValues = (valuesMap: ValuesMapType): string => {
return Object.entries(valuesMap)
.map(([name, value]) => {
const capitalizedName = capitalize(name)
if (typeof value === 'string') {
return [capitalizedName, `"${value}"`].join('=')
}
if (value instanceof Array) {
return [capitalizedName, quoteArray(value).join(', ')].join('=')
}
if (typeof value === 'object') {
return [capitalizedName, `{${renderFeatureValues(value)}}`].join('=')
}
return [capitalizedName, value].join('=')
})
.join(', ')
}
const FeatureMiddleware = ({ middleware }: { middleware: MiddlewareProps }) => {
const [name, value] = Object.entries(middleware)[0]
const content = `${capitalize(name)}: ${renderFeatureValues(value)}`
return (
<Tooltip label={content} action="copy">
<Badge variant="blue" css={{ mr: '$2', mt: '$2' }}>
{content}
</Badge>
</Tooltip>
)
}
type AdditionalFeaturesProps = {
middlewares?: MiddlewareProps[]
uid: string
}
const AdditionalFeatures = ({ middlewares, uid }: AdditionalFeaturesProps) => {
return middlewares?.length ? (
<Box css={{ mt: '-$2' }}>
{middlewares.map((m, idx) => (
<FeatureMiddleware key={`${uid}-${idx}`} middleware={m} />
))}
</Box>
) : (
<Text css={{ fontStyle: 'italic', color: 'hsl(0, 0%, 56%)' }}>No additional features</Text>
)
}
export default AdditionalFeatures

View file

@ -0,0 +1,352 @@
import { Badge, Box, Card, Flex, H2, styled, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { FiArrowRight, FiToggleLeft, FiToggleRight } from 'react-icons/fi'
import { useNavigate } from 'react-router-dom'
import { StatusWrapper } from './ResourceStatus'
import { colorByStatus } from './Status'
import Tooltip from 'components/Tooltip'
const CustomHeading = styled(H2, {
display: 'flex',
alignItems: 'center',
})
type SectionHeaderType = {
icon?: ReactNode
title?: string | undefined
}
export const SectionHeader = ({ icon, title }: SectionHeaderType) => {
if (!title) {
return (
<CustomHeading css={{ mb: '$6' }}>
<Box css={{ width: 5, height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1 }} />
<Box css={{ width: '50%', maxWidth: '300px', height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1, ml: '$2' }} />
</CustomHeading>
)
}
return (
<CustomHeading css={{ mb: '$5' }}>
{icon ? icon : null}
<Text size={6} css={{ ml: '$2' }}>
{title}
</Text>
</CustomHeading>
)
}
export const ItemTitle = styled(Text, {
marginBottom: '$3',
color: 'hsl(0, 0%, 56%)',
letterSpacing: '3px',
fontSize: '12px',
fontWeight: 600,
textAlign: 'left',
textTransform: 'uppercase',
wordBreak: 'break-word',
})
const SpacedCard = styled(Card, {
'& + &': {
marginTop: '16px',
},
})
const CardDescription = styled(Text, {
textAlign: 'left',
fontWeight: '700',
fontSize: '16px',
lineHeight: '16px',
wordBreak: 'break-word',
})
const CardListColumnWrapper = styled(Flex, {
display: 'flex',
})
const CardListColumn = styled(Flex, {
minWidth: 160,
maxWidth: '66%',
maxHeight: '416px',
overflowY: 'auto',
p: '$1',
})
const ItemBlockContainer = styled(Flex, {
maxWidth: '100%',
flexWrap: 'wrap !important',
rowGap: '$2',
// This forces the Tooltips to respect max-width, since we can't define
// it directly on the component, and the Chips are automatically covered.
span: {
maxWidth: '100%',
},
})
const FlexLink = styled('a', {
display: 'flex',
flexFlow: 'column',
textDecoration: 'none',
})
type CardType = {
title: string
description?: string
focus?: boolean
link?: string
}
type SectionType = SectionHeaderType & {
cards?: CardType[] | undefined
isLast?: boolean
bigDescription?: boolean
}
const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
return (
<SpacedCard css={{ p: '$3' }}>
<ItemTitle>
<Box css={{ height: '12px', bg: '$slate5', borderRadius: 1, mb: '$3', mr: '60%' }} />
</ItemTitle>
<CardDescription>
<Box
css={{
height: bigDescription ? '22px' : '14px',
mr: '20%',
bg: '$slate5',
borderRadius: 1,
}}
/>
</CardDescription>
</SpacedCard>
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
const navigate = useNavigate()
return (
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
<SectionHeader icon={icon} title={title} />
<CardListColumnWrapper>
<CardListColumn>
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
{!cards && <CardSkeleton bigDescription={bigDescription} />}
{cards
?.filter((c) => !!c.description)
.map((card) => (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(card.link)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
))}
<Box css={{ height: '16px' }}>&nbsp;</Box>
</Flex>
</CardListColumn>
{!isLast && (
<Flex css={{ mt: '$5', mx: 'auto' }}>
<FiArrowRight color="hsl(0, 0%, 76%)" size={24} />
</Flex>
)}
</CardListColumnWrapper>
</Flex>
)
}
const FlexCard = styled(Card, {
display: 'flex',
flexFlow: 'column',
flexGrow: '1',
overflowY: 'auto',
height: '600px',
})
const NarrowFlexCard = styled(FlexCard, {
height: '400px',
})
const ItemTitleSkeleton = styled(Box, {
height: '16px',
backgroundColor: '$slate5',
borderRadius: '3px',
})
const ItemDescriptionSkeleton = styled(Box, {
height: '16px',
backgroundColor: '$slate5',
borderRadius: '3px',
})
type DetailSectionSkeletonType = {
narrow?: boolean
}
export const DetailSectionSkeleton = ({ narrow }: DetailSectionSkeletonType) => {
const Card = narrow ? NarrowFlexCard : FlexCard
return (
<Flex css={{ flexDirection: 'column' }}>
<SectionHeader />
<Card css={{ p: '$5' }}>
<LayoutTwoCols css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '40%' }} />
<ItemTitleSkeleton css={{ width: '40%' }} />
</LayoutTwoCols>
<LayoutTwoCols css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '90%' }} />
<ItemDescriptionSkeleton css={{ width: '90%' }} />
</LayoutTwoCols>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '50%' }} />
</Flex>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '70%' }} />
</Flex>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '50%' }} />
</Flex>
<LayoutTwoCols css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '40%' }} />
<ItemTitleSkeleton css={{ width: '40%' }} />
</LayoutTwoCols>
<LayoutTwoCols css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '90%' }} />
<ItemDescriptionSkeleton css={{ width: '90%' }} />
</LayoutTwoCols>
</Card>
</Flex>
)
}
type DetailSectionType = SectionHeaderType & {
children?: ReactNode
noPadding?: boolean
narrow?: boolean
}
export const DetailSection = ({ icon, title, children, narrow, noPadding }: DetailSectionType) => {
const Card = narrow ? NarrowFlexCard : FlexCard
return (
<Flex css={{ flexDirection: 'column' }}>
<SectionHeader icon={icon} title={title} />
<Card css={{ padding: noPadding ? 0 : '$5' }}>{children}</Card>
</Flex>
)
}
const FlexLimited = styled(Flex, {
maxWidth: '100%',
margin: '0 -8px -8px 0',
span: {
maxWidth: '100%',
},
})
type ChipsType = {
items: string[]
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
alignment?: 'center' | 'left'
}
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
<FlexLimited wrap="wrap">
{items.map((item, index) => (
<Tooltip key={index} label={item} action="copy">
<Badge variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
{item}
</Badge>
</Tooltip>
))}
</FlexLimited>
)
type ChipPropsListType = {
data: {
[key: string]: string
}
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
}
export const ChipPropsList = ({ data, variant }: ChipPropsListType) => (
<Flex css={{ flexWrap: 'wrap' }}>
{Object.entries(data).map((entry: [string, string]) => (
<Badge key={entry[0]} variant={variant} css={{ textAlign: 'left', mr: '$2', mb: '$2' }}>
{entry[1]}
</Badge>
))}
</Flex>
)
type ItemBlockType = {
title: string
children?: ReactNode
}
export const ItemBlock = ({ title, children }: ItemBlockType) => (
<Flex css={{ flexDirection: 'column', mb: '$5' }}>
<ItemTitle>{title}</ItemTitle>
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
</Flex>
)
const LayoutCols = styled(Box, {
display: 'grid',
gridGap: '16px',
})
export const LayoutTwoCols = styled(LayoutCols, {
gridTemplateColumns: 'repeat(2, minmax(50%, 1fr))',
})
export const LayoutThreeCols = styled(LayoutCols, {
gridTemplateColumns: 'repeat(3, minmax(30%, 1fr))',
})
export const BooleanState = ({ enabled }: { enabled: boolean }) => (
<Flex align="center" gap={2}>
<StatusWrapper
css={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: enabled ? colorByStatus.enabled : colorByStatus.disabled,
}}
data-testid={`enabled-${enabled}`}
>
{enabled ? <FiToggleRight color="#fff" size={20} /> : <FiToggleLeft color="#fff" size={20} />}
</StatusWrapper>
<Text css={{ color: enabled ? colorByStatus.enabled : colorByStatus.disabled, fontWeight: 600 }}>
{enabled ? 'True' : 'False'}
</Text>
</Flex>
)
export const ProviderName = styled(Text, {
textTransform: 'capitalize',
overflowWrap: 'break-word',
})
export const EmptyPlaceholder = styled(Text, {
color: 'hsl(0, 0%, 76%)',
fontSize: '20px',
fontWeight: '700',
lineHeight: '1.2',
})

View file

@ -0,0 +1,45 @@
import { Box, Card, Flex, Grid, Skeleton as FaencySkeleton, Text } from '@traefiklabs/faency'
import ResourceCard from 'components/resources/ResourceCard'
const FeatureCard = ({ feature }) => {
const value = feature.value
return (
<ResourceCard title={feature.name}>
<Box
css={{
px: '$3',
borderRadius: '$2',
py: '$2',
backgroundColor: !value ? '$red6' : typeof value === 'boolean' ? '$green6' : '$gray6',
}}
>
<Text
css={{
fontSize: '$10',
fontWeight: 500,
color: !value ? '$red10' : typeof value === 'boolean' ? '$green10' : '$gray10',
textAlign: 'center',
}}
>
{!value ? 'OFF' : typeof value === 'boolean' ? 'ON' : value}
</Text>
</Box>
</ResourceCard>
)
}
export const FeatureCardSkeleton = () => {
return (
<Grid gap={6} css={{ gridTemplateColumns: 'repeat(auto-fill, minmax(215px, 1fr))' }}>
<Card css={{ minHeight: '125px' }}>
<Flex justify="space-between" align="center" direction="column" css={{ height: '100%', p: '$2' }}>
<FaencySkeleton css={{ width: 150, height: 13, mb: '$3' }} />
<FaencySkeleton css={{ width: 80, height: 40 }} />
</Flex>
</Card>
</Grid>
)
}
export default FeatureCard

View file

@ -0,0 +1,45 @@
import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency'
import { useMemo } from 'react'
import Status, { StatusType } from './Status'
import Tooltip from 'components/Tooltip'
type GenericTableProps = {
items: (number | string)[]
status?: StatusType
}
export default function GenericTable({ items, status }: GenericTableProps) {
const border = useMemo(() => `1px solid $${status === 'error' ? 'textRed' : 'tableRowBorder'}`, [status])
return (
<AriaTable css={{ wordBreak: 'break-word', boxShadow: 'none', border }}>
<AriaTbody>
{items.map((item, index) => (
<AriaTr key={index}>
<AriaTd css={{ p: '$2' }}>
<Tooltip label={item.toString()} action="copy">
<Flex align="start" gap={2} css={{ width: 'fit-content' }}>
{status ? (
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={16} />
) : (
<Text css={{ fontFamily: 'monospace', mt: '1px', userSelect: 'none' }} variant="subtle">
{index}
</Text>
)}
<Text
css={{ fontFamily: status === 'error' ? 'monospace' : undefined }}
variant={status === 'error' ? 'red' : undefined}
>
{item}
</Text>
</Flex>
</Tooltip>
</AriaTd>
</AriaTr>
))}
</AriaTbody>
</AriaTable>
)
}

View file

@ -0,0 +1,45 @@
import { AriaTable, AriaTbody, AriaTd, AriaTr, Badge, Flex, Text } from '@traefiklabs/faency'
import Tooltip from 'components/Tooltip'
export type IpStrategy = {
depth: number
excludedIPs: string[]
}
export default function IpStrategyTable({ ipStrategy }: { ipStrategy: IpStrategy }) {
return (
<AriaTable css={{ wordBreak: 'break-word', boxShadow: 'none', border: '1px solid $tableRowBorder' }}>
<AriaTbody>
{ipStrategy.depth ? (
<AriaTr>
<AriaTd css={{ width: '104px', p: '$2' }}>
<Text variant="subtle">Depth</Text>
</AriaTd>
<AriaTd css={{ p: '$2' }}>
<Tooltip label={ipStrategy.depth.toString()} action="copy">
<Text>{ipStrategy.depth}</Text>
</Tooltip>
</AriaTd>
</AriaTr>
) : null}
{ipStrategy.excludedIPs ? (
<AriaTr>
<AriaTd css={{ width: '104px', p: '$2', verticalAlign: 'baseline' }}>
<Text variant="subtle">Excluded IPs</Text>
</AriaTd>
<AriaTd css={{ p: '$2' }}>
<Flex gap={1} css={{ flexWrap: 'wrap' }}>
{ipStrategy.excludedIPs.map((ip, index) => (
<Tooltip key={index} label={ip} action="copy">
<Badge> {ip}</Badge>
</Tooltip>
))}
</Flex>
</AriaTd>
</AriaTr>
) : null}
</AriaTbody>
</AriaTable>
)
}

View file

@ -0,0 +1,113 @@
import { Box, Flex, H3, styled, Text } from '@traefiklabs/faency'
import { FiLayers } from 'react-icons/fi'
import { DetailSection, EmptyPlaceholder, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
import GenericTable from './GenericTable'
import { RenderUnknownProp } from './RenderUnknownProp'
import { ResourceStatus } from './ResourceStatus'
import { EmptyIcon } from 'components/icons/EmptyIcon'
import ProviderIcon from 'components/icons/providers'
import { Middleware, RouterDetailType } from 'hooks/use-resource-detail'
import { parseMiddlewareType } from 'libs/parsers'
const Separator = styled('hr', {
border: 'none',
background: '$tableRowBorder',
margin: '0 0 24px',
height: '1px',
minHeight: '1px',
})
const filterMiddlewareProps = (middleware: Middleware): string[] => {
const filteredProps = [] as string[]
const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers']
Object.keys(middleware).map((propName) => {
if (!propsToRemove.includes(propName)) {
filteredProps.push(propName)
}
})
return filteredProps
}
type RenderMiddlewareProps = {
middleware: Middleware
withHeader?: boolean
}
export const RenderMiddleware = ({ middleware, withHeader }: RenderMiddlewareProps) => (
<Flex key={middleware.name} css={{ flexDirection: 'column' }}>
{withHeader && <H3 css={{ mb: '$7', overflowWrap: 'break-word' }}>{middleware.name}</H3>}
<LayoutTwoCols>
{(middleware.type || middleware.plugin) && (
<ItemBlock title="Type">
<Text css={{ lineHeight: '32px', overflowWrap: 'break-word' }}>{parseMiddlewareType(middleware)}</Text>
</ItemBlock>
)}
{middleware.provider && (
<ItemBlock title="Provider">
<ProviderIcon name={middleware.provider} />
<ProviderName css={{ ml: '$2' }}>{middleware.provider}</ProviderName>
</ItemBlock>
)}
</LayoutTwoCols>
{middleware.status && (
<ItemBlock title="Status">
<ResourceStatus status={middleware.status} withLabel />
</ItemBlock>
)}
{middleware.error && (
<ItemBlock title="Errors">
<GenericTable items={middleware.error} status="error" />
</ItemBlock>
)}
{middleware.plugin &&
Object.keys(middleware.plugin).map((pluginName) => (
<RenderUnknownProp key={pluginName} name={pluginName} prop={middleware.plugin?.[pluginName]} />
))}
{filterMiddlewareProps(middleware).map((propName) => (
<RenderUnknownProp
key={propName}
name={propName}
prop={middleware[propName]}
removeTitlePrefix={middleware.type}
/>
))}
</Flex>
)
const MiddlewarePanel = ({ data }: { data: RouterDetailType }) => (
<DetailSection icon={<FiLayers size={20} />} title="Middlewares">
{data.middlewares ? (
data.middlewares.map((middleware, index) => (
<Box key={middleware.name}>
<RenderMiddleware middleware={middleware} withHeader />
{data.middlewares && index < data.middlewares.length - 1 && <Separator />}
</Box>
))
) : (
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
<Box
css={{
width: 88,
svg: {
width: '100%',
height: '100%',
},
}}
>
<EmptyIcon />
</Box>
<EmptyPlaceholder css={{ mt: '$3' }}>
There are no
<br />
Middlewares configured
</EmptyPlaceholder>
</Flex>
)}
</DetailSection>
)
export default MiddlewarePanel

View file

@ -0,0 +1,74 @@
import { useMemo } from 'react'
import Consul from 'components/icons/providers/Consul'
import Docker from 'components/icons/providers/Docker'
import ECS from 'components/icons/providers/ECS'
import Etcd from 'components/icons/providers/Etcd'
import File from 'components/icons/providers/File'
import Http from 'components/icons/providers/Http'
import Hub from 'components/icons/providers/Hub'
import Internal from 'components/icons/providers/Internal'
import Kubernetes from 'components/icons/providers/Kubernetes'
import Nomad from 'components/icons/providers/Nomad'
import Plugin from 'components/icons/providers/Plugin'
import Redis from 'components/icons/providers/Redis'
import Zookeeper from 'components/icons/providers/Zookeeper'
type ProviderIconProps = {
name: string
size?: number
}
export const ProviderIcon = ({ name, size = 32 }: ProviderIconProps) => {
const Icon = useMemo(() => {
if (!name || typeof name !== 'string') return Internal
const nameLowerCase = name.toLowerCase()
if (['consul', 'consul-', 'consulcatalog-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Consul
}
if (['docker', 'swarm'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Docker
}
if (['ecs'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return ECS
}
if (['etcd'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Etcd
}
if (['file'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return File
}
if (['http'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Http
}
if (['hub'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Hub
}
if (['kubernetes'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Kubernetes
}
if (['nomad', 'nomad-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Nomad
}
if (['plugin', 'plugin-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Plugin
}
if (['redis'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Redis
}
if (['zookeeper'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Zookeeper
}
return Internal
}, [name])
return (
<Icon
height={size}
width={size}
style={{ backgroundColor: 'var(--colors-primary)', borderRadius: '50%', color: 'var(--colors-01dp)' }}
/>
)
}

View file

@ -0,0 +1,162 @@
import { RenderUnknownProp } from './RenderUnknownProp'
import { renderWithProviders } from 'utils/test'
describe('<RenderUnknownProp />', () => {
it('renders a string correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringPropName" prop="string prop value" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('string prop value')
})
it('renders a number correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="NumberPropName" prop={123123} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('NumberPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('123123')
})
it('renders false correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={false} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders boolean true correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={true} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders boolean false correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={false} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders string `true` correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringBoolPropName" prop="true" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringBoolPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders string `false` correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringBoolPropName" prop="false" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringBoolPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders empty object correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="EmptyObjectPropName" prop={{}} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('EmptyObjectPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders list of strings correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp name="StringListPropName" prop={['string1', 'string2', 'string3']} />,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringListPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('string1')
expect(container.querySelector('div > div')?.innerHTML).toContain('string2')
expect(container.querySelector('div > div')?.innerHTML).toContain('string3')
})
it('renders list of objects correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="ObjectListPropName"
prop={[{ array: [] }, { otherObject: {} }, { word: 'test' }, { number: 123 }, { boolean: false, or: true }]}
/>,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('ObjectListPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"array":[]}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"otherObject":{}}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"word":"test"}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"number":123}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"boolean":false,"or":true}')
})
it('renders recursive objects correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
prop={{
parentProperty: {
childProperty: {
valueProperty1: 'test',
valueProperty2: ['item1', 'item2', 'item3'],
},
},
}}
/>,
)
expect(container.querySelector('div:first-child > span')?.innerHTML).toContain(
'RecursiveObjectPropName &gt; parent Property &gt; child Property &gt; value Property1',
)
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
'RecursiveObjectPropName &gt; parent Property &gt; child Property &gt; value Property2',
)
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item1')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item2')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item3')
})
it('renders recursive objects removing title prefix correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
removeTitlePrefix="RecursiveObjectPropName"
prop={{
parentProperty: {
childProperty: {
valueProperty1: 'test',
valueProperty2: ['item1', 'item2', 'item3'],
},
},
}}
/>,
)
expect(container.querySelector('div:first-child > span')?.innerHTML).toContain(
'parent Property &gt; child Property &gt; value Property1',
)
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
'parent Property &gt; child Property &gt; value Property2',
)
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item1')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item2')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item3')
})
it(`renders should not remove prefix if there's no child`, () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
removeTitlePrefix="RecursiveObjectPropName"
prop="DummyValue"
/>,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('RecursiveObjectPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('DummyValue')
})
})

View file

@ -0,0 +1,76 @@
import { Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { BooleanState, ItemBlock } from './DetailSections'
import GenericTable from './GenericTable'
import IpStrategyTable, { IpStrategy } from './IpStrategyTable'
import Tooltip from 'components/Tooltip'
type RenderUnknownPropProps = {
name: string
prop?: unknown
removeTitlePrefix?: string
}
export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnknownPropProps) => {
const wrap = (children: ReactNode, altName?: string, key?: string) => (
<ItemBlock key={key} title={altName || name}>
{children}
</ItemBlock>
)
try {
if (typeof prop !== 'undefined') {
if (typeof prop === 'boolean') {
return wrap(<BooleanState enabled={prop} />)
}
if (typeof prop === 'string' && ['true', 'false'].includes((prop as string).toLowerCase())) {
return wrap(<BooleanState enabled={prop === 'true'} />)
}
if (['string', 'number'].includes(typeof prop)) {
return wrap(
<Tooltip label={prop as string} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{prop as string}</Text>
</Tooltip>,
)
}
if (JSON.stringify(prop) === '{}') {
return wrap(<BooleanState enabled />)
}
if (prop instanceof Array) {
return wrap(
<GenericTable items={prop.map((p) => (['number', 'string'].includes(typeof p) ? p : JSON.stringify(p)))} />,
)
}
if (prop?.constructor === Object) {
return (
<>
{Object.entries(prop).map(([childName, childProp]) => {
const spacedChildName = childName.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
let title = `${name} > ${spacedChildName}`
if (removeTitlePrefix) {
title = title.replace(new RegExp(`^${removeTitlePrefix} > `, 'i'), '')
}
switch (childName) {
case 'ipStrategy':
return wrap(<IpStrategyTable ipStrategy={childProp as IpStrategy} />, title, title)
default:
return <RenderUnknownProp key={title} name={title} prop={childProp} />
}
})}
</>
)
}
}
} catch (error) {
console.log('Unable to render plugin property:', { name, prop }, { error })
}
return null
}

View file

@ -0,0 +1,26 @@
import { Card, CSS, Flex, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
type ResourceCardProps = {
children: ReactNode
css?: CSS
title?: string
titleCSS?: CSS
}
const ResourceCard = ({ children, css, title, titleCSS = {} }: ResourceCardProps) => {
return (
<Card css={css}>
<Flex direction="column" align="center" justify="center" gap={3} css={{ height: '100%', p: '$2' }}>
{title && (
<Text variant="subtle" css={{ letterSpacing: 3, fontSize: '$2', wordBreak: 'break-all', ...titleCSS }}>
{title.toUpperCase()}
</Text>
)}
{children}
</Flex>
</Card>
)
}
export default ResourceCard

View file

@ -0,0 +1,71 @@
import { Flex, styled, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
export const StatusWrapper = styled(Flex, {
height: '32px',
width: '32px',
padding: 0,
borderRadius: '4px',
})
type Props = {
status: StatusType
label?: string
withLabel?: boolean
}
type Value = { color: string; icon: ReactNode; label: string }
export const ResourceStatus = ({ status, withLabel = false }: Props) => {
const valuesByStatus: { [key in StatusType]: Value } = {
info: {
color: colorByStatus.info,
icon: iconByStatus.info,
label: 'Info',
},
success: {
color: colorByStatus.success,
icon: iconByStatus.success,
label: 'Success',
},
warning: {
color: colorByStatus.warning,
icon: iconByStatus.warning,
label: 'Warning',
},
error: {
color: colorByStatus.error,
icon: iconByStatus.error,
label: 'Error',
},
enabled: {
color: colorByStatus.enabled,
icon: iconByStatus.enabled,
label: 'Success',
},
disabled: {
color: colorByStatus.disabled,
icon: iconByStatus.disabled,
label: 'Error',
},
}
const values = valuesByStatus[status]
if (!values) {
return null
}
return (
<Flex css={{ alignItems: 'center' }} data-testid={status}>
<StatusWrapper css={{ alignItems: 'center', justifyContent: 'center', backgroundColor: values.color }}>
{values.icon}
</StatusWrapper>
{withLabel && values.label && (
<Text css={{ ml: '$2', color: values.color, fontWeight: 600 }}>{values.label}</Text>
)}
</Flex>
)
}

View file

@ -0,0 +1,76 @@
import { Badge, Text } from '@traefiklabs/faency'
import { FiInfo } from 'react-icons/fi'
import { DetailSection, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
import GenericTable from './GenericTable'
import { ResourceStatus } from './ResourceStatus'
import ProviderIcon from 'components/icons/providers'
import Tooltip from 'components/Tooltip'
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
type Props = {
data: ResourceDetailDataType
}
const RouterPanel = ({ data }: Props) => (
<DetailSection icon={<FiInfo size={20} />} title="Router Details">
<LayoutTwoCols>
{data.status && (
<ItemBlock title="Status">
<ResourceStatus status={data.status} withLabel />
</ItemBlock>
)}
{data.provider && (
<ItemBlock title="Provider">
<ProviderIcon name={data.provider} />
<ProviderName css={{ ml: '$2' }}>{data.provider}</ProviderName>
</ItemBlock>
)}
{data.priority && (
<ItemBlock title="Priority">
<Tooltip label={data.priority.toString()} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.priority.toString()}</Text>
</Tooltip>
</ItemBlock>
)}
</LayoutTwoCols>
{data.rule ? (
<ItemBlock title="Rule">
<Tooltip label={data.rule} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.rule}</Text>
</Tooltip>
</ItemBlock>
) : null}
{data.name && (
<ItemBlock title="Name">
<Tooltip label={data.name} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.name}</Text>
</Tooltip>
</ItemBlock>
)}
{!!data.using && data.using && data.using.length > 0 && (
<ItemBlock title="Entrypoints">
{data.using.map((ep) => (
<Tooltip key={ep} label={ep} action="copy">
<Badge css={{ mr: '$2' }}>{ep}</Badge>
</Tooltip>
))}
</ItemBlock>
)}
{data.service && (
<ItemBlock title="Service">
<Tooltip label={data.service} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.service}</Text>
</Tooltip>
</ItemBlock>
)}
{data.error && (
<ItemBlock title="Errors">
<GenericTable items={data.error} status="error" />
</ItemBlock>
)}
</DetailSection>
)
export default RouterPanel

View file

@ -0,0 +1,68 @@
import { Box, CSS } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { FiAlertCircle, FiAlertTriangle, FiCheckCircle } from 'react-icons/fi'
export type StatusType = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled'
export const iconByStatus: { [key in StatusType]: ReactNode } = {
info: <FiAlertCircle color="white" size={20} />,
success: <FiCheckCircle color="white" size={20} />,
warning: <FiAlertCircle color="white" size={20} />,
error: <FiAlertTriangle color="white" size={20} />,
enabled: <FiCheckCircle color="white" size={20} />,
disabled: <FiAlertTriangle color="white" size={20} />,
}
// Please notice: dark and light colors have the same values.
export const colorByStatus: { [key in StatusType]: string } = {
info: 'hsl(220, 67%, 51%)',
success: '#30A46C',
warning: 'hsl(24 94.0% 50.0%)',
error: 'hsl(347, 100%, 60.0%)',
enabled: '#30A46C',
disabled: 'hsl(347, 100%, 60.0%)',
}
type StatusProps = {
css?: CSS
size?: number
status: StatusType
}
export default function Status({ css = {}, size = 20, status }: StatusProps) {
const Icon = ({ size }: { size: number }) => {
switch (status) {
case 'info':
return <FiAlertCircle color="white" size={size} />
case 'success':
return <FiCheckCircle color="white" size={size} />
case 'warning':
return <FiAlertCircle color="white" size={size} />
case 'error':
return <FiAlertTriangle color="white" size={size} />
case 'enabled':
return <FiCheckCircle color="white" size={size} />
case 'disabled':
return <FiAlertTriangle color="white" size={size} />
default:
return null
}
}
return (
<Box
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
backgroundColor: colorByStatus[status],
marginRight: '10px',
padding: '6px',
...css,
}}
>
<Icon size={size} />
</Box>
)
}

View file

@ -0,0 +1,77 @@
import { Badge, Box, Flex, Text } from '@traefiklabs/faency'
import { FiShield } from 'react-icons/fi'
import { BooleanState, DetailSection, EmptyPlaceholder, ItemBlock } from './DetailSections'
import { EmptyIcon } from 'components/icons/EmptyIcon'
import { RouterDetailType } from 'hooks/use-resource-detail'
type Props = {
data: RouterDetailType
}
const TlsPanel = ({ data }: Props) => (
<DetailSection icon={<FiShield size={20} />} title="TLS">
{data.tls ? (
<Flex css={{ flexDirection: 'column' }}>
<ItemBlock title="TLS">
<BooleanState enabled />
</ItemBlock>
{data.tls.options && (
<ItemBlock title="Options">
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.options}</Text>
</ItemBlock>
)}
<ItemBlock title="PassThrough">
<BooleanState enabled={!!data.tls.passthrough} />
</ItemBlock>
{data.tls.certResolver && (
<ItemBlock title="Certificate Resolver">
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.certResolver}</Text>
</ItemBlock>
)}
{data.tls.domains && (
<ItemBlock title="Domains">
<Flex css={{ flexDirection: 'column' }}>
{data.tls.domains?.map((domain) => (
<Flex key={domain.main} css={{ flexWrap: 'wrap' }}>
<a href={`//${domain.main}`}>
<Badge variant="blue" css={{ mr: '$2', mb: '$2', color: '$primary', borderColor: '$primary' }}>
{domain.main}
</Badge>
</a>
{domain.sans?.map((sub) => (
<a key={sub} href={`//${sub}`}>
<Badge css={{ mr: '$2', mb: '$2' }}>{sub}</Badge>
</a>
))}
</Flex>
))}
</Flex>
</ItemBlock>
)}
</Flex>
) : (
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
<Box
css={{
width: 88,
svg: {
width: '100%',
height: '100%',
},
}}
>
<EmptyIcon />
</Box>
<EmptyPlaceholder css={{ mt: '$3' }}>
There is no
<br />
TLS configured
</EmptyPlaceholder>
</Flex>
)}
</DetailSection>
)
export default TlsPanel

View file

@ -0,0 +1,53 @@
import TraefikResourceStatsCard from './TraefikResourceStatsCard'
import { renderWithProviders } from 'utils/test'
describe('<TraefikResourceStatsCard />', () => {
it('should render the component and show the expected data (success count is zero)', () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={2} total={5} warnings={3} linkTo="" />,
)
expect(getByTestId('success-pc').innerHTML).toContain('0%')
expect(getByTestId('success-count').innerHTML).toContain('0')
expect(getByTestId('warnings-pc').innerHTML).toContain('60%')
expect(getByTestId('warnings-count').innerHTML).toContain('3')
expect(getByTestId('errors-pc').innerHTML).toContain('40%')
expect(getByTestId('errors-count').innerHTML).toContain('2')
})
it('should render the component and show the expected data (success count is not zero)', async () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={2} total={7} warnings={4} linkTo="" />,
)
expect(getByTestId('success-pc').innerHTML).toContain('14%')
expect(getByTestId('success-count').innerHTML).toContain('1')
expect(getByTestId('warnings-pc').innerHTML).toContain('57%')
expect(getByTestId('warnings-count').innerHTML).toContain('4')
expect(getByTestId('errors-pc').innerHTML).toContain('29%')
expect(getByTestId('errors-count').innerHTML).toContain('2')
})
it('should not render the component when everything is zero', async () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={0} total={0} warnings={0} linkTo="" />,
)
expect(() => {
getByTestId('success-pc')
}).toThrow('Unable to find an element by: [data-testid="success-pc"]')
expect(() => {
getByTestId('success-count')
}).toThrow('Unable to find an element by: [data-testid="success-count"]')
expect(() => {
getByTestId('warnings-pc')
}).toThrow('Unable to find an element by: [data-testid="warnings-pc"]')
expect(() => {
getByTestId('warnings-count')
}).toThrow('Unable to find an element by: [data-testid="warnings-count"]')
expect(() => {
getByTestId('errors-pc')
}).toThrow('Unable to find an element by: [data-testid="errors-pc"]')
expect(() => {
getByTestId('errors-count')
}).toThrow('Unable to find an element by: [data-testid="errors-count"]')
})
})

View file

@ -0,0 +1,216 @@
import { Box, Card, Flex, H3, Skeleton, styled, Text } from '@traefiklabs/faency'
import { Chart as ChartJs, ArcElement, Tooltip } from 'chart.js'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Doughnut } from 'react-chartjs-2'
import { FaArrowRightLong } from 'react-icons/fa6'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import Status, { colorByStatus, StatusType } from './Status'
import { capitalizeFirstLetter } from 'utils/string'
ChartJs.register(ArcElement, Tooltip)
const Link = styled(RouterLink, {
textDecoration: 'none',
'&:hover': {
textDecoration: 'none',
},
})
type StatsCardType = {
children: ReactNode
}
const StatsCard = ({ children, ...props }: StatsCardType) => (
<Card
css={{
display: 'flex',
flexDirection: 'column',
padding: '16px',
overflow: 'hidden',
}}
{...props}
>
{children}
</Card>
)
export type TraefikResourceStatsType = {
title?: string
errors: number
total: number
warnings: number
}
export type TraefikResourceStatsCardProps = TraefikResourceStatsType & {
linkTo: string
}
export type DataType = {
datasets: {
backgroundColor: string[]
data: (string | number)[]
}[]
labels?: string[]
}
const getPercent = (total: number, value: number) => (total > 0 ? ((value * 100) / total).toFixed(0) : 0)
const STATS_ATTRIBUTES: { status: StatusType; label: string }[] = [
{
status: 'enabled',
label: 'success',
},
{
status: 'warning',
label: 'warnings',
},
{
status: 'disabled',
label: 'errors',
},
]
const CustomLegend = ({
status,
label,
count,
total,
linkTo,
}: {
status: StatusType
label: string
count: number
total: number
linkTo: string
}) => {
return (
<Link to={`${linkTo}?status=${status}`}>
<Flex css={{ alignItems: 'center', p: '$2' }}>
<Status status={status} />
<Flex css={{ flexDirection: 'column', flex: 1 }}>
<Text css={{ fontWeight: 600 }}>{capitalizeFirstLetter(label)}</Text>
<Text size={1} css={{ color: 'hsl(0, 0%, 56%)' }} data-testid={`${label}-pc`}>
{getPercent(total, count)}%
</Text>
</Flex>
<Text size={5} css={{ fontWeight: 700 }} data-testid={`${label}-count`}>
{count}
</Text>
</Flex>
</Link>
)
}
const TraefikResourceStatsCard = ({ title, errors, total, warnings, linkTo }: TraefikResourceStatsCardProps) => {
const navigate = useNavigate()
const defaultData = {
datasets: [
{
backgroundColor: [colorByStatus.enabled],
data: [1],
},
],
}
const [data, setData] = useState<DataType>(defaultData)
const counts = useMemo(
() => ({
success: total - (errors + warnings),
warnings,
errors,
}),
[errors, total, warnings],
)
useEffect(() => {
if (counts.success + counts.warnings + counts.errors === 0) {
setData(defaultData)
return
}
const newData = {
datasets: [
{
backgroundColor: [colorByStatus.enabled, colorByStatus.warning, colorByStatus.error],
data: [counts.success, counts.warnings, counts.errors],
},
],
labels: ['Success', 'Warnings', 'Errors'],
}
setData(newData)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [errors, warnings, total, counts])
const options = {
animation: {
duration: 1000,
},
plugins: {
legend: {
display: false,
},
},
tooltips: {
enabled: true,
},
maintainAspectRatio: false,
onClick: (_, activeEl) => {
navigate(`${linkTo}?status=${STATS_ATTRIBUTES[activeEl[0].index].status}`)
},
}
if (!errors && !total && !warnings) return null
return (
<StatsCard data-testid="card">
{title && (
<Flex css={{ pb: '$3', mb: '$2' }}>
{title && (
<Flex align="center" justify="space-between" css={{ flex: '1' }}>
<H3 css={{ fontSize: '$6' }}>{title}</H3>
<Link to={linkTo as string}>
<Flex align="center" gap={1} css={{ color: '$primary' }}>
<Text css={{ fontWeight: 500, color: '$primary' }}>Explore</Text>
<FaArrowRightLong />
</Flex>
</Link>
</Flex>
)}
</Flex>
)}
<Flex css={{ flex: '1' }}>
<Box css={{ width: '50%' }}>
<Doughnut data={data} options={options} />
</Box>
<Box css={{ width: '50%' }}>
{STATS_ATTRIBUTES.map((i) => (
<CustomLegend key={`${title}-${i.label}`} {...i} count={counts[i.label]} total={total} linkTo={linkTo} />
))}
</Box>
</Flex>
</StatsCard>
)
}
export const StatsCardSkeleton = () => {
return (
<StatsCard>
<Flex gap={2}>
<Skeleton css={{ width: '80%', height: 150 }} />
<Flex direction="column" gap={2} css={{ flex: 1 }}>
<Skeleton />
<Skeleton />
<Skeleton />
</Flex>
</Flex>
</StatsCard>
)
}
export default TraefikResourceStatsCard

View file

@ -0,0 +1,146 @@
import { AriaTable, AriaTbody, AriaTd, AriaTh, AriaThead, AriaTr, Box, Flex, styled } from '@traefiklabs/faency'
import { orderBy } from 'lodash'
import { useContext, useEffect, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { SectionHeader } from 'components/resources/DetailSections'
import SortableTh from 'components/tables/SortableTh'
import { ToastContext } from 'contexts/toasts'
import { MiddlewareDetailType, ServiceDetailType } from 'hooks/use-resource-detail'
import { makeRowRender } from 'pages/http/HttpRouters'
type UsedByRoutersSectionProps = {
data: ServiceDetailType | MiddlewareDetailType
protocol?: string
}
const SkeletonContent = styled(Box, {
backgroundColor: '$slate5',
height: '14px',
minWidth: '50px',
borderRadius: '4px',
margin: '8px',
})
export const UsedByRoutersSkeleton = () => (
<Flex css={{ flexDirection: 'column', mt: '40px' }}>
<SectionHeader />
<AriaTable>
<AriaThead>
<AriaTr>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
</AriaTr>
</AriaThead>
<AriaTbody>
<AriaTr css={{ pointerEvents: 'none' }}>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
</AriaTr>
<AriaTr css={{ pointerEvents: 'none' }}>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
</AriaTr>
</AriaTbody>
</AriaTable>
</Flex>
)
export const UsedByRoutersSection = ({ data, protocol = 'http' }: UsedByRoutersSectionProps) => {
const renderRow = makeRowRender(protocol)
const [searchParams] = useSearchParams()
const { addToast } = useContext(ToastContext)
const routersFound = useMemo(() => {
let routers = data.routers?.filter((r) => !r.message)
const direction = (searchParams.get('direction') as 'asc' | 'desc' | null) || 'asc'
const sortBy = searchParams.get('sortBy') || 'name'
if (sortBy) routers = orderBy(routers, [sortBy], [direction || 'asc'])
return routers
}, [data, searchParams])
const routersNotFound = useMemo(() => data.routers?.filter((r) => !!r.message), [data])
useEffect(() => {
routersNotFound?.map((error) =>
addToast({
message: error.message,
severity: 'error',
}),
)
}, [addToast, routersNotFound])
if (!routersFound || routersFound.length <= 0) {
return null
}
return (
<Flex css={{ flexDirection: 'column', mt: '$5' }}>
<SectionHeader title="Used by Routers" />
<AriaTable data-testid="routers-table">
<AriaThead>
<AriaTr>
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
{protocol !== 'udp' ? <SortableTh css={{ width: '40px' }} label="TLS" /> : null}
{protocol !== 'udp' ? <SortableTh label="Rule" isSortable sortByValue="rule" /> : null}
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
<SortableTh label="Name" isSortable sortByValue="name" />
<SortableTh label="Service" isSortable sortByValue="service" />
<SortableTh label="Provider" css={{ width: '40px' }} isSortable sortByValue="provider" />
<SortableTh label="Priority" isSortable sortByValue="priority" />
</AriaTr>
</AriaThead>
<AriaTbody>{routersFound.map(renderRow)}</AriaTbody>
</AriaTable>
</Flex>
)
}