Migrate Traefik Proxy dashboard UI to React
This commit is contained in:
parent
4790e4910f
commit
f16fff577a
324 changed files with 28303 additions and 19567 deletions
53
webui/src/components/resources/AdditionalFeatures.spec.tsx
Normal file
53
webui/src/components/resources/AdditionalFeatures.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
73
webui/src/components/resources/AdditionalFeatures.tsx
Normal file
73
webui/src/components/resources/AdditionalFeatures.tsx
Normal 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
|
||||
352
webui/src/components/resources/DetailSections.tsx
Normal file
352
webui/src/components/resources/DetailSections.tsx
Normal 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' }}> </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',
|
||||
})
|
||||
45
webui/src/components/resources/FeatureCard.tsx
Normal file
45
webui/src/components/resources/FeatureCard.tsx
Normal 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
|
||||
45
webui/src/components/resources/GenericTable.tsx
Normal file
45
webui/src/components/resources/GenericTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
webui/src/components/resources/IpStrategyTable.tsx
Normal file
45
webui/src/components/resources/IpStrategyTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
113
webui/src/components/resources/MiddlewarePanel.tsx
Normal file
113
webui/src/components/resources/MiddlewarePanel.tsx
Normal 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
|
||||
74
webui/src/components/resources/ProviderIcon.tsx
Normal file
74
webui/src/components/resources/ProviderIcon.tsx
Normal 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)' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
162
webui/src/components/resources/RenderUnknownProp.spec.tsx
Normal file
162
webui/src/components/resources/RenderUnknownProp.spec.tsx
Normal 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 > parent Property > child Property > value Property1',
|
||||
)
|
||||
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
|
||||
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
|
||||
'RecursiveObjectPropName > parent Property > child Property > 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 > child Property > value Property1',
|
||||
)
|
||||
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
|
||||
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
|
||||
'parent Property > child Property > 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')
|
||||
})
|
||||
})
|
||||
76
webui/src/components/resources/RenderUnknownProp.tsx
Normal file
76
webui/src/components/resources/RenderUnknownProp.tsx
Normal 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
|
||||
}
|
||||
26
webui/src/components/resources/ResourceCard.tsx
Normal file
26
webui/src/components/resources/ResourceCard.tsx
Normal 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
|
||||
71
webui/src/components/resources/ResourceStatus.tsx
Normal file
71
webui/src/components/resources/ResourceStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
webui/src/components/resources/RouterPanel.tsx
Normal file
76
webui/src/components/resources/RouterPanel.tsx
Normal 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
|
||||
68
webui/src/components/resources/Status.tsx
Normal file
68
webui/src/components/resources/Status.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
webui/src/components/resources/TlsPanel.tsx
Normal file
77
webui/src/components/resources/TlsPanel.tsx
Normal 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
|
||||
|
|
@ -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"]')
|
||||
})
|
||||
})
|
||||
216
webui/src/components/resources/TraefikResourceStatsCard.tsx
Normal file
216
webui/src/components/resources/TraefikResourceStatsCard.tsx
Normal 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
|
||||
146
webui/src/components/resources/UsedByRoutersSection.tsx
Normal file
146
webui/src/components/resources/UsedByRoutersSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue