diff --git a/webui/package.json b/webui/package.json index b75ac6d1b..4d6e64f3c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -101,5 +101,5 @@ "public" ] }, - "packageManager": "yarn@4.9.1" + "packageManager": "yarn@4.12.0" } diff --git a/webui/src/components/CopyableText.tsx b/webui/src/components/CopyableText.tsx new file mode 100644 index 000000000..42c8f1bf2 --- /dev/null +++ b/webui/src/components/CopyableText.tsx @@ -0,0 +1,39 @@ +import { CSS, Text } from '@traefiklabs/faency' +import { useContext } from 'react' + +import CopyButton from 'components/buttons/CopyButton' +import { ToastContext } from 'contexts/toasts' + +type CopyableTextProps = { + notifyText?: string + text: string + css?: CSS +} + +export default function CopyableText({ notifyText, text, css }: CopyableTextProps) { + const { addToast } = useContext(ToastContext) + + return ( + + {text} + { + if (notifyText) addToast({ message: notifyText, severity: 'success' }) + }} + css={{ display: 'inline-block', height: 20, verticalAlign: 'middle', ml: '$1' }} + iconOnly + /> + + ) +} diff --git a/webui/src/components/ScrollableCard.tsx b/webui/src/components/ScrollableCard.tsx new file mode 100644 index 000000000..cb2fc1403 --- /dev/null +++ b/webui/src/components/ScrollableCard.tsx @@ -0,0 +1,13 @@ +import { Card, styled } from '@traefiklabs/faency' + +const ScrollableCard = styled(Card, { + width: '100%', + maxHeight: 300, + overflowY: 'auto', + overflowX: 'hidden', + scrollbarWidth: 'thin', + scrollbarColor: '$colors-primary $colors-01dp', + scrollbarGutter: 'stable', +}) + +export default ScrollableCard diff --git a/webui/src/components/Toast.tsx b/webui/src/components/Toast.tsx index 81ff31c16..30a836cec 100644 --- a/webui/src/components/Toast.tsx +++ b/webui/src/components/Toast.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion' import { ReactNode, useEffect } from 'react' import { FiX } from 'react-icons/fi' -import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status' +import { colorByStatus, iconByStatus } from 'components/resources/Status' const CloseButton = styled(Button, { position: 'absolute', @@ -39,7 +39,7 @@ const toastVariants = { } export type ToastState = { - severity: StatusType + severity: Resource.Status message?: string isVisible?: boolean key?: string @@ -88,7 +88,7 @@ export const Toast = ({ message, dismiss, severity = 'error', icon, isVisible = exit="hidden" variants={toastVariants} > - {icon ? icon : propsBySeverity[severity].icon} + {icon ? icon : propsBySeverity[severity].icon} {message} {!timeout && ( diff --git a/webui/src/components/buttons/CopyButton.tsx b/webui/src/components/buttons/CopyButton.tsx new file mode 100644 index 000000000..08f60240e --- /dev/null +++ b/webui/src/components/buttons/CopyButton.tsx @@ -0,0 +1,57 @@ +import { Flex, Button, CSS, AccessibleIcon } from '@traefiklabs/faency' +import React, { useState } from 'react' +import { FiCheck, FiCopy } from 'react-icons/fi' + +type CopyButtonProps = { + text: string + disabled?: boolean + css?: CSS + onClick?: () => void + iconOnly?: boolean + title?: string + color?: string +} + +const CopyButton = ({ + text, + disabled, + css, + onClick, + iconOnly = false, + title = 'Copy', + color = 'var(--colors-textSubtle)', +}: CopyButtonProps) => { + const [showConfirmation, setShowConfirmation] = useState(false) + + return ( + + ) +} + +export default CopyButton diff --git a/webui/src/components/ScrollTopButton.tsx b/webui/src/components/buttons/ScrollTopButton.tsx similarity index 100% rename from webui/src/components/ScrollTopButton.tsx rename to webui/src/components/buttons/ScrollTopButton.tsx diff --git a/webui/src/components/icons/providers/index.tsx b/webui/src/components/icons/providers/index.tsx index 8745a7644..cb65ebb27 100644 --- a/webui/src/components/icons/providers/index.tsx +++ b/webui/src/components/icons/providers/index.tsx @@ -1,3 +1,4 @@ +import { Box } from '@traefiklabs/faency' import { HTMLAttributes, useMemo } from 'react' import Consul from 'components/icons/providers/Consul' @@ -14,13 +15,14 @@ import Nomad from 'components/icons/providers/Nomad' import Plugin from 'components/icons/providers/Plugin' import Redis from 'components/icons/providers/Redis' import Zookeeper from 'components/icons/providers/Zookeeper' +import Tooltip from 'components/Tooltip' export type ProviderIconProps = HTMLAttributes & { height?: number | string width?: number | string } -export default function ProviderIcon({ name, size = 32 }: { name: string; size?: number }) { +export default function ProviderIcon({ name, size = 20 }: { name: string; size?: number }) { const Icon = useMemo(() => { if (!name || typeof name !== 'string') return Internal @@ -76,3 +78,13 @@ export default function ProviderIcon({ name, size = 32 }: { name: string; size?: /> ) } + +export const ProviderIconWithTooltip = ({ provider, size = 20 }) => { + return ( + + + + + + ) +} diff --git a/webui/src/components/middlewares/MiddlewareDefinition.tsx b/webui/src/components/middlewares/MiddlewareDefinition.tsx new file mode 100644 index 000000000..5601ce6e0 --- /dev/null +++ b/webui/src/components/middlewares/MiddlewareDefinition.tsx @@ -0,0 +1,40 @@ +import { useMemo } from 'react' + +import ProviderIcon from 'components/icons/providers' +import { ProviderName } from 'components/resources/DetailItemComponents' +import DetailsCard from 'components/resources/DetailsCard' +import { ResourceStatus } from 'components/resources/ResourceStatus' +import { parseMiddlewareType } from 'libs/parsers' + +type MiddlewareDefinitionProps = { + data: Middleware.Details + testId?: string +} + +const MiddlewareDefinition = ({ data, testId }: MiddlewareDefinitionProps) => { + const providerName = useMemo(() => { + return data.provider + }, [data.provider]) + + const detailsItems = useMemo( + () => + [ + data.status && { key: 'Status', val: }, + (data.type || data.plugin) && { key: 'Type', val: parseMiddlewareType(data) }, + data.provider && { + key: 'Provider', + val: ( + <> + + {providerName} + + ), + }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement }[], + [data, providerName], + ) + + return +} + +export default MiddlewareDefinition diff --git a/webui/src/components/middlewares/MiddlewareDetail.tsx b/webui/src/components/middlewares/MiddlewareDetail.tsx new file mode 100644 index 000000000..14ca8f290 --- /dev/null +++ b/webui/src/components/middlewares/MiddlewareDetail.tsx @@ -0,0 +1,100 @@ +import { Card, Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet-async' + +import MiddlewareDefinition from './MiddlewareDefinition' +import { RenderUnknownProp } from './RenderUnknownProp' + +import { DetailsCardSkeleton } from 'components/resources/DetailsCard' +import ResourceErrors, { ResourceErrorsSkeleton } from 'components/resources/ResourceErrors' +import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' +import { NotFound } from 'pages/NotFound' + +type MiddlewareDetailProps = { + data?: Resource.DetailsData + error?: Error | null + name: string + protocol: 'http' | 'tcp' +} + +const filterMiddlewareProps = (middleware: Middleware.Details): string[] => { + const filteredProps = [] as string[] + const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers'] + + Object.keys(middleware).map((propName) => { + if (!propsToRemove.includes(propName)) { + filteredProps.push(propName) + } + }) + + return filteredProps +} + +export const MiddlewareDetail = ({ data, error, name, protocol }: MiddlewareDetailProps) => { + const filteredProps = useMemo(() => { + if (data) { + return filterMiddlewareProps(data) + } + + return [] + }, [data]) + + if (error) { + return ( + <> + + {name} - Traefik Proxy + + + Sorry, we could not fetch detail information for this Middleware right now. Please, try again later. + + + ) + } + + if (!data) { + return ( + <> + + {name} - Traefik Proxy + + + + + + + + + ) + } + + if (!data.name) { + return + } + + return ( + <> + + {data.name} - Traefik Proxy + +

{data.name}

+ + + {!!data.error && } + {(!!data.plugin || !!filteredProps.length) && ( + + {data.plugin && + Object.keys(data.plugin).map((pluginName) => ( + + ))} + {filteredProps?.map((propName) => ( + + ))} + + )} + + + + + ) +} diff --git a/webui/src/components/resources/RenderUnknownProp.spec.tsx b/webui/src/components/middlewares/RenderUnknownProp.spec.tsx similarity index 100% rename from webui/src/components/resources/RenderUnknownProp.spec.tsx rename to webui/src/components/middlewares/RenderUnknownProp.spec.tsx diff --git a/webui/src/components/resources/RenderUnknownProp.tsx b/webui/src/components/middlewares/RenderUnknownProp.tsx similarity index 74% rename from webui/src/components/resources/RenderUnknownProp.tsx rename to webui/src/components/middlewares/RenderUnknownProp.tsx index e4f0d118e..40520d33e 100644 --- a/webui/src/components/resources/RenderUnknownProp.tsx +++ b/webui/src/components/middlewares/RenderUnknownProp.tsx @@ -1,11 +1,9 @@ -import { Text } from '@traefiklabs/faency' import { ReactNode } from 'react' -import { BooleanState, ItemBlock } from './DetailSections' -import GenericTable from './GenericTable' -import IpStrategyTable, { IpStrategy } from './IpStrategyTable' - -import Tooltip from 'components/Tooltip' +import CopyableText from 'components/CopyableText' +import { BooleanState, ItemBlock } from 'components/resources/DetailItemComponents' +import GenericTable from 'components/resources/GenericTable' +import IpStrategyTable, { IpStrategy } from 'components/resources/IpStrategyTable' type RenderUnknownPropProps = { name: string @@ -22,23 +20,19 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno try { if (typeof prop !== 'undefined') { if (typeof prop === 'boolean') { - return wrap() + return wrap() } if (typeof prop === 'string' && ['true', 'false'].includes((prop as string).toLowerCase())) { - return wrap() + return wrap() } if (['string', 'number'].includes(typeof prop)) { - return wrap( - - {prop as string} - , - ) + return wrap() } if (JSON.stringify(prop) === '{}') { - return wrap() + return wrap() } if (prop instanceof Array) { @@ -75,7 +69,7 @@ export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnkno } } } catch (error) { - console.log('Unable to render plugin property:', { name, prop }, { error }) + console.error('Unable to render plugin property:', { name, prop }, { error }) } return null diff --git a/webui/src/components/resources/AdditionalFeatures.spec.tsx b/webui/src/components/resources/AdditionalFeatures.spec.tsx deleted file mode 100644 index d1c448b19..000000000 --- a/webui/src/components/resources/AdditionalFeatures.spec.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import AdditionalFeatures from './AdditionalFeatures' - -import { MiddlewareProps } from 'hooks/use-resource-detail' -import { renderWithProviders } from 'utils/test' - -describe('', () => { - it('should render the middleware info', () => { - renderWithProviders() - }) - - it('should render the middleware info with number', () => { - const middlewares: MiddlewareProps[] = [ - { - retry: { - attempts: 2, - }, - }, - ] - - const { container } = renderWithProviders() - - expect(container.innerHTML).toContain('Retry: Attempts=2') - }) - - it('should render the middleware info with string', () => { - const middlewares: MiddlewareProps[] = [ - { - circuitBreaker: { - expression: 'expression', - }, - }, - ] - - const { container } = renderWithProviders() - - 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() - - expect(container.innerHTML).toContain('RateLimit: Burst=100, Average=100') - }) -}) diff --git a/webui/src/components/resources/AdditionalFeatures.tsx b/webui/src/components/resources/AdditionalFeatures.tsx deleted file mode 100644 index 2b95db582..000000000 --- a/webui/src/components/resources/AdditionalFeatures.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Badge, Box, Text } from '@traefiklabs/faency' - -import Tooltip from 'components/Tooltip' -import { MiddlewareProps, ValuesMapType } from 'hooks/use-resource-detail' - -function capitalize(word: string): string { - return word.charAt(0).toUpperCase() + word.slice(1) -} - -function quote(value: string | number): string | number { - if (typeof value === 'string') { - return `"${value}"` - } - - return value -} - -function quoteArray(values: (string | number)[]): (string | number)[] { - return values.map(quote) -} - -const renderFeatureValues = (valuesMap: ValuesMapType): string => { - return Object.entries(valuesMap) - .map(([name, value]) => { - const capitalizedName = capitalize(name) - if (typeof value === 'string') { - return [capitalizedName, `"${value}"`].join('=') - } - - if (value instanceof Array) { - return [capitalizedName, quoteArray(value).join(', ')].join('=') - } - - if (typeof value === 'object') { - return [capitalizedName, `{${renderFeatureValues(value)}}`].join('=') - } - - return [capitalizedName, value].join('=') - }) - .join(', ') -} - -const FeatureMiddleware = ({ middleware }: { middleware: MiddlewareProps }) => { - const [name, value] = Object.entries(middleware)[0] - const content = `${capitalize(name)}: ${renderFeatureValues(value)}` - - return ( - - - {content} - - - ) -} - -type AdditionalFeaturesProps = { - middlewares?: MiddlewareProps[] - uid: string -} - -const AdditionalFeatures = ({ middlewares, uid }: AdditionalFeaturesProps) => { - return middlewares?.length ? ( - - {middlewares.map((m, idx) => ( - - ))} - - ) : ( - No additional features - ) -} - -export default AdditionalFeatures diff --git a/webui/src/components/resources/DetailItemComponents.tsx b/webui/src/components/resources/DetailItemComponents.tsx new file mode 100644 index 000000000..b4891216a --- /dev/null +++ b/webui/src/components/resources/DetailItemComponents.tsx @@ -0,0 +1,95 @@ +import { Badge, CSS, Flex, styled, Text } from '@traefiklabs/faency' +import { ReactNode } from 'react' +import { BsToggleOff, BsToggleOn } from 'react-icons/bs' + +import { colorByStatus } from './Status' + +import CopyButton from 'components/buttons/CopyButton' + +export const ItemTitle = styled(Text, { + marginBottom: '$3', + color: 'hsl(0, 0%, 56%)', + fontSize: '12px', + fontWeight: 600, + textAlign: 'left', + textTransform: 'capitalize', + wordBreak: 'break-word', +}) + +const ItemBlockContainer = styled(Flex, { + maxWidth: '100%', + flexWrap: 'wrap !important', + rowGap: '$2', + + // This forces the Tooltips to respect max-width, since we can't define + // it directly on the component, and the Chips are automatically covered. + span: { + maxWidth: '100%', + }, +}) + +const FlexLimited = styled(Flex, { + maxWidth: '100%', + margin: '0 -8px -8px 0', + span: { + maxWidth: '100%', + }, +}) + +type ChipsType = { + items: string[] + variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple' + alignment?: 'center' | 'left' +} + +export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => ( + + {items.map((item, index) => ( + + + {item} + + + + ))} + +) + +type ItemBlockType = { + title: string + children?: ReactNode +} + +export const ItemBlock = ({ title, children }: ItemBlockType) => ( + + {title} + {children} + +) + +export const BooleanState = ({ enabled, css }: { enabled: boolean; css?: CSS }) => ( + + {enabled ? ( + + ) : ( + + )} + + + {enabled ? 'True' : 'False'} + + +) + +export const ProviderName = styled(Text, { + textTransform: 'capitalize', + overflowWrap: 'break-word', + fontSize: 'inherit !important', +}) + +export const EmptyPlaceholder = styled(Text, { + color: 'hsl(0, 0%, 76%)', + fontSize: '20px', + fontWeight: '700', + lineHeight: '1.2', +}) diff --git a/webui/src/components/resources/DetailSections.tsx b/webui/src/components/resources/DetailSections.tsx deleted file mode 100644 index 53e1ee670..000000000 --- a/webui/src/components/resources/DetailSections.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import { Badge, Box, Card, Flex, H2, styled, Text } from '@traefiklabs/faency' -import { ReactNode } from 'react' -import { FiArrowRight, FiToggleLeft, FiToggleRight } from 'react-icons/fi' -import { useNavigate } from 'react-router-dom' - -import { StatusWrapper } from './ResourceStatus' -import { colorByStatus } from './Status' - -import Tooltip from 'components/Tooltip' -import { useGetUrlWithReturnTo } from 'hooks/use-href-with-return-to' - -const CustomHeading = styled(H2, { - display: 'flex', - alignItems: 'center', -}) - -type SectionHeaderType = { - icon?: ReactNode - title?: string | undefined -} - -export const SectionHeader = ({ icon, title }: SectionHeaderType) => { - if (!title) { - return ( - - - - - ) - } - - return ( - - {icon ? icon : null} - - {title} - - - ) -} - -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 ( - - - - - - - - - ) -} - -const CardItem = ({ card }) => { - const navigate = useNavigate() - const href = useGetUrlWithReturnTo(card.link) - - return ( - - !!card.link && navigate(href)} - css={{ cursor: card.link ? 'pointer' : 'inherit' }} - > - {card.title} - {card.description} - - - ) -} - -export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => { - return ( - - - - - - {!cards && } - {cards?.filter((c) => !!c.description).map((card, idx) => )} -   - - - {!isLast && ( - - - - )} - - - ) -} - -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 ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} - -type DetailSectionType = SectionHeaderType & { - children?: ReactNode - noPadding?: boolean - narrow?: boolean -} - -export const DetailSection = ({ icon, title, children, narrow, noPadding }: DetailSectionType) => { - const Card = narrow ? NarrowFlexCard : FlexCard - - return ( - - - {children} - - ) -} - -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) => ( - - {items.map((item, index) => ( - - - {item} - - - ))} - -) - -type ChipPropsListType = { - data: { - [key: string]: string - } - variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple' -} - -export const ChipPropsList = ({ data, variant }: ChipPropsListType) => ( - - {Object.entries(data).map((entry: [string, string]) => ( - - {entry[1]} - - ))} - -) - -type ItemBlockType = { - title: string - children?: ReactNode -} - -export const ItemBlock = ({ title, children }: ItemBlockType) => ( - - {title} - {children} - -) - -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 }) => ( - - - {enabled ? : } - - - {enabled ? 'True' : 'False'} - - -) - -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', -}) diff --git a/webui/src/components/resources/DetailsCard.tsx b/webui/src/components/resources/DetailsCard.tsx new file mode 100644 index 000000000..463e0c095 --- /dev/null +++ b/webui/src/components/resources/DetailsCard.tsx @@ -0,0 +1,207 @@ +import { Card, CSS, Flex, Grid, H2, Skeleton, styled, Text } from '@traefiklabs/faency' +import { Fragment, ReactNode, useMemo } from 'react' + +import ScrollableCard from 'components/ScrollableCard' +import breakpoints from 'utils/breakpoints' + +const StyledText = styled(Text, { + fontSize: 'inherit !important', + lineHeight: '24px', +}) + +export const ValText = styled(StyledText, { + overflowWrap: 'break-word', + wordBreak: 'break-word', +}) + +export const SectionTitle = ({ icon, title }: { icon?: ReactNode; title: string }) => { + return ( + + {icon && icon} +

{title}

+
+ ) +} + +type DetailsCardProps = { + css?: CSS + keyColumns?: number + items: { key: string; val: string | React.ReactElement; stackVertical?: boolean; forceNewRow?: boolean }[] + minKeyWidth?: string + maxKeyWidth?: string + testidPrefix?: string + testId?: string + title?: string + icon?: ReactNode + scrollable?: boolean +} + +export default function DetailsCard({ + css = {}, + keyColumns = 2, + items, + minKeyWidth, + maxKeyWidth, + testidPrefix = 'definition', + testId, + title, + icon, + scrollable = false, +}: DetailsCardProps) { + const ParentComponent = useMemo(() => { + if (scrollable) { + return ScrollableCard + } + return Card + }, [scrollable]) + + return ( + + {title ? : null} + + + {items.map((item, index) => { + // Handle forceNewRow props + const cellsBeforeThis = items.slice(0, index).reduce((count, prevItem) => { + if (prevItem.stackVertical) return count + keyColumns + return count + 1 + }, 0) + + const needsEmptyCell = item.forceNewRow && cellsBeforeThis % keyColumns !== 0 + + return ( + + {needsEmptyCell && ( + <> +
+
+ + )} + {item.stackVertical ? ( + + + {item.key} + + {typeof item.val === 'string' ? ( + {item.val} + ) : ( + *': { + height: 'fit-content', + }, + height: '100%', + }} + > + {item.val} + + )} + + ) : ( + <> + + {index < keyColumns + ? items + .filter((hiddenItem) => hiddenItem.key != item.key) + .map((hiddenItem, jndex) => ( + + )) + : null} + + {item.key} + + + {typeof item.val === 'string' ? ( + {item.val} + ) : ( + *': { + height: 'fit-content', + }, + height: '100%', + }} + > + {item.val} + + )} + + )} + + ) + })} + + + + ) +} + +export function DetailsCardSkeleton({ + keyColumns = 2, + rows = 3, + testidPrefix = 'definition', + title, + icon, +}: { rows?: number } & Omit) { + return ( + + {title ? : } + + + {[...Array(rows * keyColumns)].map((_, idx) => ( + + + + + ))} + + + + ) +} diff --git a/webui/src/components/resources/GenericTable.tsx b/webui/src/components/resources/GenericTable.tsx index 4ebacbefd..7dce66af5 100644 --- a/webui/src/components/resources/GenericTable.tsx +++ b/webui/src/components/resources/GenericTable.tsx @@ -1,16 +1,17 @@ import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency' import { useMemo } from 'react' -import Status, { StatusType } from './Status' +import Status from './Status' -import Tooltip from 'components/Tooltip' +import CopyableText from 'components/CopyableText' type GenericTableProps = { items: (number | string)[] - status?: StatusType + status?: Resource.Status + copyable?: boolean } -export default function GenericTable({ items, status }: GenericTableProps) { +export default function GenericTable({ items, status, copyable = false }: GenericTableProps) { const border = useMemo(() => `1px solid $${status === 'error' ? 'textRed' : 'tableRowBorder'}`, [status]) return ( @@ -19,23 +20,31 @@ export default function GenericTable({ items, status }: GenericTableProps) { {items.map((item, index) => ( - - - {status ? ( - - ) : ( - - {index} - - )} + + {status ? ( + + ) : ( + + {index} + + )} + {copyable ? ( + + ) : ( {item} - - + )} + ))} diff --git a/webui/src/components/resources/MiddlewarePanel.tsx b/webui/src/components/resources/MiddlewarePanel.tsx deleted file mode 100644 index 37806ac0d..000000000 --- a/webui/src/components/resources/MiddlewarePanel.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { Box, Flex, H3, styled, Text } from '@traefiklabs/faency' -import { FiLayers } from 'react-icons/fi' - -import { DetailSection, EmptyPlaceholder, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections' -import GenericTable from './GenericTable' -import { RenderUnknownProp } from './RenderUnknownProp' -import { ResourceStatus } from './ResourceStatus' - -import { EmptyIcon } from 'components/icons/EmptyIcon' -import ProviderIcon from 'components/icons/providers' -import { Middleware, RouterDetailType } from 'hooks/use-resource-detail' -import { parseMiddlewareType } from 'libs/parsers' - -const Separator = styled('hr', { - border: 'none', - background: '$tableRowBorder', - margin: '0 0 24px', - height: '1px', - minHeight: '1px', -}) - -const filterMiddlewareProps = (middleware: Middleware): string[] => { - const filteredProps = [] as string[] - const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers'] - - Object.keys(middleware).map((propName) => { - if (!propsToRemove.includes(propName)) { - filteredProps.push(propName) - } - }) - - return filteredProps -} - -type RenderMiddlewareProps = { - middleware: Middleware - withHeader?: boolean -} - -export const RenderMiddleware = ({ middleware, withHeader }: RenderMiddlewareProps) => ( - - {withHeader &&

{middleware.name}

} - - {(middleware.type || middleware.plugin) && ( - - {parseMiddlewareType(middleware)} - - )} - {middleware.provider && ( - - - {middleware.provider} - - )} - - {middleware.status && ( - - - - )} - {middleware.error && ( - - - - )} - {middleware.plugin && - Object.keys(middleware.plugin).map((pluginName) => ( - - ))} - {filterMiddlewareProps(middleware).map((propName) => ( - - ))} -
-) - -const MiddlewarePanel = ({ data }: { data: RouterDetailType }) => ( - } title="Middlewares"> - {data.middlewares ? ( - data.middlewares.map((middleware, index) => ( - - - {data.middlewares && index < data.middlewares.length - 1 && } - - )) - ) : ( - - - - - - There are no -
- Middlewares configured -
-
- )} -
-) - -export default MiddlewarePanel diff --git a/webui/src/components/resources/ResourceErrors.tsx b/webui/src/components/resources/ResourceErrors.tsx new file mode 100644 index 000000000..e4ba60aa2 --- /dev/null +++ b/webui/src/components/resources/ResourceErrors.tsx @@ -0,0 +1,31 @@ +import { Card, Flex, Skeleton } from '@traefiklabs/faency' +import { FiAlertTriangle } from 'react-icons/fi' + +import { SectionTitle } from './DetailsCard' +import GenericTable from './GenericTable' + +const ResourceErrors = ({ errors }: { errors: string[] }) => { + return ( + + } /> + + + + + ) +} + +export const ResourceErrorsSkeleton = () => { + return ( + + + + {[...Array(4)].map((_, idx) => ( + + ))} + + + ) +} + +export default ResourceErrors diff --git a/webui/src/components/resources/ResourceStatus.tsx b/webui/src/components/resources/ResourceStatus.tsx index 51e3a7e81..d50040b5b 100644 --- a/webui/src/components/resources/ResourceStatus.tsx +++ b/webui/src/components/resources/ResourceStatus.tsx @@ -1,25 +1,26 @@ -import { Flex, styled, Text } from '@traefiklabs/faency' +import { Box, Flex, styled, Text } from '@traefiklabs/faency' import { ReactNode } from 'react' -import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status' +import { colorByStatus, iconByStatus } from 'components/resources/Status' export const StatusWrapper = styled(Flex, { - height: '32px', - width: '32px', + height: '24px', + width: '24px', padding: 0, borderRadius: '4px', }) type Props = { - status: StatusType + status: Resource.Status label?: string withLabel?: boolean + size?: number } type Value = { color: string; icon: ReactNode; label: string } -export const ResourceStatus = ({ status, withLabel = false }: Props) => { - const valuesByStatus: { [key in StatusType]: Value } = { +export const ResourceStatus = ({ status, withLabel = false, size = 20 }: Props) => { + const valuesByStatus: { [key in Resource.Status]: Value } = { info: { color: colorByStatus.info, icon: iconByStatus.info, @@ -50,6 +51,11 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => { icon: iconByStatus.disabled, label: 'Error', }, + loading: { + color: colorByStatus.loading, + icon: iconByStatus.loading, + label: 'Loading...', + }, } const values = valuesByStatus[status] @@ -59,12 +65,12 @@ export const ResourceStatus = ({ status, withLabel = false }: Props) => { } return ( - - - {values.icon} - + + {values.icon} {withLabel && values.label && ( - {values.label} + + {values.label} + )} ) diff --git a/webui/src/components/resources/RouterPanel.tsx b/webui/src/components/resources/RouterPanel.tsx deleted file mode 100644 index c380df50e..000000000 --- a/webui/src/components/resources/RouterPanel.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Badge, Text } from '@traefiklabs/faency' -import { FiInfo } from 'react-icons/fi' - -import { DetailSection, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections' -import GenericTable from './GenericTable' -import { ResourceStatus } from './ResourceStatus' - -import ProviderIcon from 'components/icons/providers' -import Tooltip from 'components/Tooltip' -import { ResourceDetailDataType } from 'hooks/use-resource-detail' - -type Props = { - data: ResourceDetailDataType -} - -const RouterPanel = ({ data }: Props) => ( - } title="Router Details"> - - {data.status && ( - - - - )} - {data.provider && ( - - - {data.provider} - - )} - {data.priority && ( - - - {data.priority.toString()} - - - )} - - {data.rule ? ( - - - {data.rule} - - - ) : null} - {data.name && ( - - - {data.name} - - - )} - {!!data.using && data.using && data.using.length > 0 && ( - - {data.using.map((ep) => ( - - {ep} - - ))} - - )} - {data.service && ( - - - {data.service} - - - )} - {data.error && ( - - - - )} - -) - -export default RouterPanel diff --git a/webui/src/components/resources/Status.tsx b/webui/src/components/resources/Status.tsx index d67c126e9..d90799f58 100644 --- a/webui/src/components/resources/Status.tsx +++ b/webui/src/components/resources/Status.tsx @@ -1,49 +1,50 @@ import { Box, CSS } from '@traefiklabs/faency' import { ReactNode } from 'react' -import { FiAlertCircle, FiAlertTriangle, FiCheckCircle } from 'react-icons/fi' +import { FiAlertCircle, FiAlertTriangle, FiCheckCircle, FiLoader } from 'react-icons/fi' -export type StatusType = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' - -export const iconByStatus: { [key in StatusType]: ReactNode } = { - info: , - success: , - warning: , - error: , - enabled: , - disabled: , +export const iconByStatus: { [key in Resource.Status]: ReactNode } = { + info: , + success: , + warning: , + error: , + enabled: , + disabled: , + loading: , } // Please notice: dark and light colors have the same values. -export const colorByStatus: { [key in StatusType]: string } = { +export const colorByStatus: { [key in Resource.Status]: string } = { info: 'hsl(220, 67%, 51%)', success: '#30A46C', warning: 'hsl(24 94.0% 50.0%)', error: 'hsl(347, 100%, 60.0%)', enabled: '#30A46C', disabled: 'hsl(347, 100%, 60.0%)', + loading: 'hsla(0, 0%, 100%, 0.51)', } type StatusProps = { css?: CSS size?: number - status: StatusType + status: Resource.Status + color?: string } -export default function Status({ css = {}, size = 20, status }: StatusProps) { +export default function Status({ css = {}, size = 20, status, color = 'white' }: StatusProps) { const Icon = ({ size }: { size: number }) => { switch (status) { case 'info': - return + return case 'success': - return + return case 'warning': - return + return case 'error': - return + return case 'enabled': - return + return case 'disabled': - return + return default: return null } diff --git a/webui/src/components/resources/TlsPanel.tsx b/webui/src/components/resources/TlsPanel.tsx deleted file mode 100644 index a5890d707..000000000 --- a/webui/src/components/resources/TlsPanel.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { Badge, Box, Flex, Text } from '@traefiklabs/faency' -import { FiShield } from 'react-icons/fi' - -import { BooleanState, DetailSection, EmptyPlaceholder, ItemBlock } from './DetailSections' - -import { EmptyIcon } from 'components/icons/EmptyIcon' -import { RouterDetailType } from 'hooks/use-resource-detail' - -type Props = { - data: RouterDetailType -} - -const TlsPanel = ({ data }: Props) => ( - } title="TLS"> - {data.tls ? ( - - - - - {data.tls.options && ( - - {data.tls.options} - - )} - - - - {data.tls.certResolver && ( - - {data.tls.certResolver} - - )} - {data.tls.domains && ( - - - {data.tls.domains?.map((domain) => ( - - - - {domain.main} - - - {domain.sans?.map((sub) => ( - - {sub} - - ))} - - ))} - - - )} - - ) : ( - - - - - - There is no -
- TLS configured -
-
- )} -
-) - -export default TlsPanel diff --git a/webui/src/components/resources/TraefikResourceStatsCard.tsx b/webui/src/components/resources/TraefikResourceStatsCard.tsx index 15dfa4508..60c59812d 100644 --- a/webui/src/components/resources/TraefikResourceStatsCard.tsx +++ b/webui/src/components/resources/TraefikResourceStatsCard.tsx @@ -5,7 +5,7 @@ import { Doughnut } from 'react-chartjs-2' import { FaArrowRightLong } from 'react-icons/fa6' import { Link as RouterLink, useNavigate } from 'react-router-dom' -import Status, { colorByStatus, StatusType } from './Status' +import Status, { colorByStatus } from './Status' import { capitalizeFirstLetter } from 'utils/string' @@ -58,7 +58,7 @@ export type DataType = { const getPercent = (total: number, value: number) => (total > 0 ? ((value * 100) / total).toFixed(0) : 0) -const STATS_ATTRIBUTES: { status: StatusType; label: string }[] = [ +const STATS_ATTRIBUTES: { status: Resource.Status; label: string }[] = [ { status: 'enabled', label: 'success', @@ -80,7 +80,7 @@ const CustomLegend = ({ total, linkTo, }: { - status: StatusType + status: Resource.Status label: string count: number total: number diff --git a/webui/src/components/resources/UsedByRoutersSection.tsx b/webui/src/components/resources/UsedByRoutersSection.tsx index 5eec1e465..a80c630ee 100644 --- a/webui/src/components/resources/UsedByRoutersSection.tsx +++ b/webui/src/components/resources/UsedByRoutersSection.tsx @@ -1,96 +1,24 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTh, AriaThead, AriaTr, Box, Flex, styled } from '@traefiklabs/faency' +import { Flex } from '@traefiklabs/faency' import { orderBy } from 'lodash' import { useContext, useEffect, useMemo } from 'react' import { useSearchParams } from 'react-router-dom' -import { SectionHeader } from 'components/resources/DetailSections' -import SortableTh from 'components/tables/SortableTh' +import { SectionTitle } from './DetailsCard' + +import AriaTableSkeleton from 'components/tables/AriaTableSkeleton' +import PaginatedTable from 'components/tables/PaginatedTable' import { ToastContext } from 'contexts/toasts' -import { MiddlewareDetailType, ServiceDetailType } from 'hooks/use-resource-detail' import { makeRowRender } from 'pages/http/HttpRouters' type UsedByRoutersSectionProps = { - data: ServiceDetailType | MiddlewareDetailType + data: Service.Details | Middleware.DetailsData protocol?: string } -const SkeletonContent = styled(Box, { - backgroundColor: '$slate5', - height: '14px', - minWidth: '50px', - borderRadius: '4px', - margin: '8px', -}) - export const UsedByRoutersSkeleton = () => ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + ) @@ -118,29 +46,38 @@ export const UsedByRoutersSection = ({ data, protocol = 'http' }: UsedByRoutersS ) }, [addToast, routersNotFound]) + const columns = useMemo((): Array<{ + key: keyof Router.DetailsData + header: string + sortable?: boolean + width?: string + }> => { + return [ + { key: 'status', header: 'Status', sortable: true, width: '36px' }, + ...(protocol !== 'udp' ? [{ key: 'tls' as keyof Router.DetailsData, header: 'TLS', width: '24px' }] : []), + ...(protocol !== 'udp' ? [{ key: 'rule' as keyof Router.DetailsData, header: 'Rule', sortable: true }] : []), + { key: 'using', header: 'Entrypoints', sortable: true }, + { key: 'name', header: 'Name', sortable: true }, + { key: 'service', header: 'Service', sortable: true }, + { key: 'provider', header: 'Provider', sortable: true, width: '40px' }, + { key: 'priority', header: 'Priority', sortable: true }, + ] + }, [protocol]) + if (!routersFound || routersFound.length <= 0) { return null } return ( - - - - - - - - {protocol !== 'udp' ? : null} - {protocol !== 'udp' ? : null} - - - - - - - - {routersFound.map(renderRow)} - + + + ) } diff --git a/webui/src/components/routers/RouterDetail.tsx b/webui/src/components/routers/RouterDetail.tsx new file mode 100644 index 000000000..5f13369ab --- /dev/null +++ b/webui/src/components/routers/RouterDetail.tsx @@ -0,0 +1,67 @@ +import { Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { useMemo } from 'react' +import { Helmet } from 'react-helmet-async' + +import { DetailsCardSkeleton } from 'components/resources/DetailsCard' +import ResourceErrors, { ResourceErrorsSkeleton } from 'components/resources/ResourceErrors' +import RouterFlowDiagram, { RouterFlowDiagramSkeleton } from 'components/routers/RouterFlowDiagram' +import TlsSection from 'components/routers/TlsSection' +import { NotFound } from 'pages/NotFound' + +type RouterDetailProps = { + data?: Resource.DetailsData + error?: Error | null + name: string + protocol: 'http' | 'tcp' | 'udp' +} + +export const RouterDetail = ({ data, error, name, protocol }: RouterDetailProps) => { + const isUdp = useMemo(() => protocol === 'udp', [protocol]) + + if (error) { + return ( + <> + + {name} - Traefik Proxy + + + Sorry, we could not fetch detail information for this Router right now. Please, try again later. + + + ) + } + + if (!data) { + return ( + <> + + {name} - Traefik Proxy + + + + + + + + + ) + } + + if (!data.name) { + return + } + + return ( + <> + + {data.name} - Traefik Proxy + +

{data.name}

+ + + {data?.error && } + {!isUdp && } + + + ) +} diff --git a/webui/src/components/routers/RouterFlowDiagram.tsx b/webui/src/components/routers/RouterFlowDiagram.tsx new file mode 100644 index 000000000..870f69cd8 --- /dev/null +++ b/webui/src/components/routers/RouterFlowDiagram.tsx @@ -0,0 +1,212 @@ +import { Card, Flex, styled, Link, Tooltip, Box, Text, Skeleton } from '@traefiklabs/faency' +import { useMemo } from 'react' +import { FiArrowRight, FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi' + +import CopyableText from 'components/CopyableText' +import ProviderIcon from 'components/icons/providers' +import { ProviderName } from 'components/resources/DetailItemComponents' +import DetailsCard, { SectionTitle } from 'components/resources/DetailsCard' +import { ResourceStatus } from 'components/resources/ResourceStatus' +import ScrollableCard from 'components/ScrollableCard' +import { useHrefWithReturnTo } from 'hooks/use-href-with-return-to' +import { useResourceDetail } from 'hooks/use-resource-detail' + +const FlexContainer = styled(Flex, { + gap: '$3', + flexDirection: 'column !important', + alignItems: 'center !important', + flex: '1 1 0', + minWidth: '0', + maxWidth: '100%', +}) + +const ArrowSeparator = () => { + return ( + + + + ) +} + +const LinkedNameAndStatus = ({ data }: { data: { status: Resource.Status; name: string; href?: string } }) => { + const hrefWithReturnTo = useHrefWithReturnTo(data?.href || '') + + if (!data.href) { + return ( + + + + + + + + + {data.name} + + + ) + } + return ( + + + + {data.name} + + + ) +} + +type RouterFlowDiagramProps = { + data: Resource.DetailsData + protocol: 'http' | 'tcp' | 'udp' +} + +const RouterFlowDiagram = ({ data, protocol }: RouterFlowDiagramProps) => { + const displayedEntrypoints = useMemo(() => { + return data?.entryPointsData?.map((point) => { + if (!point.message) { + return { key: point.name, val: point.address } + } else { + return { key: point.message, val: '' } + } + }) + }, [data?.entryPointsData]) + + const routerDetailsItems = useMemo( + () => + [ + data.status && { key: 'Status', val: }, + data.provider && { + key: 'Provider', + val: ( + <> + + {data.provider} + + ), + }, + data.priority && { key: 'Priority', val: data.priority }, + data.rule && { key: 'Rule', val: }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement }[], + [data.priority, data.provider, data.rule, data.status], + ) + + const serviceSlug = data.service?.includes('@') + ? data.service + : `${data.service ?? 'unknown'}@${data.provider ?? 'unknown'}` + + const { data: serviceData, error: serviceDataError } = useResourceDetail(serviceSlug ?? '', 'services') + + return ( + + {!!data.using?.length && ( + <> + + } title="Entrypoints" /> + {displayedEntrypoints?.length ? ( + + ) : ( + + )} + + + + + )} + + + } title={`${protocol.toUpperCase()} Router`} /> + + + + {data.hasValidMiddlewares && ( + <> + + + } title={`${protocol.toUpperCase()} Middlewares`} /> + {data.middlewares ? ( + + + {data.middlewares.map((mw, idx) => { + const data = { + name: mw.name, + status: mw.status, + href: `/${protocol}/middlewares/${mw.name}`, + } + return + })} + + + ) : ( + + )} + + + )} + + + + + } title="Service" /> + + + + + + ) +} + +const DiagramCardSkeleton = () => { + return ( + + {[...Array(5)].map((_, idx) => ( + + ))} + + ) +} + +export const RouterFlowDiagramSkeleton = () => { + return ( + + {[...Array(4)].map((_, index) => [ + + + + , + index < 3 && , + ])} + + ) +} + +export default RouterFlowDiagram diff --git a/webui/src/components/routers/TlsIcon.tsx b/webui/src/components/routers/TlsIcon.tsx new file mode 100644 index 000000000..b6a93bede --- /dev/null +++ b/webui/src/components/routers/TlsIcon.tsx @@ -0,0 +1,7 @@ +import { FiShield } from 'react-icons/fi' + +const TlsIcon = ({ size = 20 }: { size?: number }) => { + return +} + +export default TlsIcon diff --git a/webui/src/components/routers/TlsSection.tsx b/webui/src/components/routers/TlsSection.tsx new file mode 100644 index 000000000..7938b87a6 --- /dev/null +++ b/webui/src/components/routers/TlsSection.tsx @@ -0,0 +1,78 @@ +import { Badge, Box, Card, Flex } from '@traefiklabs/faency' +import { useMemo } from 'react' + +import TlsIcon from './TlsIcon' + +import { EmptyIcon } from 'components/icons/EmptyIcon' +import { BooleanState, EmptyPlaceholder } from 'components/resources/DetailItemComponents' +import DetailsCard, { SectionTitle } from 'components/resources/DetailsCard' + +type Props = { + data?: Router.TLS +} + +const TlsSection = ({ data }: Props) => { + const items = useMemo(() => { + if (data) { + return [ + data?.options && { key: 'Options', val: data.options }, + { key: 'Passthrough', val: }, + data?.certResolver && { key: 'Certificate resolver', val: data.certResolver }, + data?.domains && { + stackVertical: true, + forceNewRow: true, + key: 'Domains', + val: ( + + {data.domains?.map((domain) => ( + + + + {domain.main} + + + {domain.sans?.map((sub) => ( + + {sub} + + ))} + + ))} + + ), + }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement }[] + } + }, [data]) + return ( + + } title="TLS" /> + {items?.length ? ( + + ) : ( + + + + + + + There is no +
+ TLS configured +
+
+
+ )} +
+ ) +} + +export default TlsSection diff --git a/webui/src/components/services/MirrorServices.tsx b/webui/src/components/services/MirrorServices.tsx new file mode 100644 index 000000000..b551afbc7 --- /dev/null +++ b/webui/src/components/services/MirrorServices.tsx @@ -0,0 +1,42 @@ +import { Flex, Text } from '@traefiklabs/faency' +import { FiGlobe } from 'react-icons/fi' + +import { getProviderFromName } from './Servers' + +import ProviderIcon from 'components/icons/providers' +import { SectionTitle } from 'components/resources/DetailsCard' +import PaginatedTable from 'components/tables/PaginatedTable' + +type MirrorServicesProps = { + mirrors: Service.Mirror[] + defaultProvider: string +} + +const MirrorServices = ({ mirrors, defaultProvider }: MirrorServicesProps) => { + return ( + + } title="Mirror Services" /> + ({ + name: mirror.name, + percent: mirror.percent, + provider: getProviderFromName(mirror.name, defaultProvider), + }))} + columns={[ + { key: 'name', header: 'Name' }, + { key: 'percent', header: 'Percent' }, + { key: 'provider', header: 'Provider' }, + ]} + testId="mirror-services" + renderCell={(key, value) => { + if (key === 'provider') { + return + } + return {value} + }} + /> + + ) +} + +export default MirrorServices diff --git a/webui/src/components/services/Servers.tsx b/webui/src/components/services/Servers.tsx new file mode 100644 index 000000000..684a7d143 --- /dev/null +++ b/webui/src/components/services/Servers.tsx @@ -0,0 +1,93 @@ +import { Flex, Text } from '@traefiklabs/faency' +import { useMemo } from 'react' +import { FiGlobe } from 'react-icons/fi' + +import { SectionTitle } from 'components/resources/DetailsCard' +import { ResourceStatus } from 'components/resources/ResourceStatus' +import { colorByStatus } from 'components/resources/Status' +import PaginatedTable from 'components/tables/PaginatedTable' +import Tooltip from 'components/Tooltip' + +type ServersProps = { + data: Service.Details + protocol: 'http' | 'tcp' | 'udp' +} + +type Server = { + url?: string + address?: string +} + +type ServerStatus = { + [server: string]: string +} + +function getServerStatusList(data: Service.Details): ServerStatus { + const serversList: ServerStatus = {} + + data.loadBalancer?.servers?.forEach((server: Server) => { + const serverKey = server.address || server.url + if (serverKey) { + serversList[serverKey] = 'DOWN' + } + }) + + if (data.serverStatus) { + Object.entries(data.serverStatus).forEach(([server, status]) => { + serversList[server] = status + }) + } + + return serversList +} + +export const getProviderFromName = (serviceName: string, defaultProvider: string): string => { + const [, provider] = serviceName.split('@') + return provider || defaultProvider +} + +const Servers = ({ data, protocol }: ServersProps) => { + const serversList = getServerStatusList(data) + + const isTcp = useMemo(() => protocol === 'tcp', [protocol]) + const isUdp = useMemo(() => protocol === 'udp', [protocol]) + + if (!Object.keys(serversList)?.length) return null + + return ( + + } title="Servers" /> + ({ + server, + status, + }))} + columns={[ + ...(isUdp ? [] : [{ key: 'status' as const, header: 'Status' }]), + { key: 'server' as const, header: isTcp ? 'Address' : 'URL' }, + ]} + testId="servers-list" + renderCell={(key, value) => { + if (key === 'status') { + return ( + + + {value} + + ) + } + if (key === 'server') { + return ( + + {value} + + ) + } + return {value} + }} + /> + + ) +} + +export default Servers diff --git a/webui/src/components/services/ServiceDefinition.tsx b/webui/src/components/services/ServiceDefinition.tsx new file mode 100644 index 000000000..3cd3b16c5 --- /dev/null +++ b/webui/src/components/services/ServiceDefinition.tsx @@ -0,0 +1,58 @@ +import { Badge } from '@traefiklabs/faency' +import { useMemo } from 'react' + +import ProviderIcon from 'components/icons/providers' +import { BooleanState, ProviderName } from 'components/resources/DetailItemComponents' +import DetailsCard from 'components/resources/DetailsCard' +import { ResourceStatus } from 'components/resources/ResourceStatus' + +type ServiceDefinitionProps = { + data: Service.Details + testId?: string +} + +const ServiceDefinition = ({ data, testId }: ServiceDefinitionProps) => { + const providerName = useMemo(() => { + return data.provider + }, [data.provider]) + + const detailsItems = useMemo( + () => + [ + data.status && { key: 'Status', val: }, + data.type && { key: 'Type', val: data.type }, + data.provider && { + key: 'Provider', + val: ( + <> + + {providerName} + + ), + }, + data.mirroring && + data.mirroring.service && { key: 'Main service', val: {data.mirroring.service} }, + data.loadBalancer?.passHostHeader && { + key: 'Pass host header', + val: , + }, + data.loadBalancer?.terminationDelay && { + key: 'Termination delay', + val: `${data.loadBalancer.terminationDelay} ms`, + }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement }[], + [ + data.loadBalancer?.passHostHeader, + data.loadBalancer?.terminationDelay, + data.mirroring, + data.provider, + data.status, + data.type, + providerName, + ], + ) + + return +} + +export default ServiceDefinition diff --git a/webui/src/components/services/ServiceDetail.tsx b/webui/src/components/services/ServiceDetail.tsx new file mode 100644 index 000000000..86adf8b26 --- /dev/null +++ b/webui/src/components/services/ServiceDetail.tsx @@ -0,0 +1,87 @@ +import { Box, Flex, H1, Skeleton, Text } from '@traefiklabs/faency' +import { Helmet } from 'react-helmet-async' + +import MirrorServices from './MirrorServices' +import Servers from './Servers' +import ServiceDefinition from './ServiceDefinition' +import ServiceHealthCheck from './ServiceHealthCheck' +import WeightedServices from './WeightedServices' + +import { DetailsCardSkeleton } from 'components/resources/DetailsCard' +import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' +import AriaTableSkeleton from 'components/tables/AriaTableSkeleton' +import { NotFound } from 'pages/NotFound' + +type ServiceDetailProps = { + data?: Resource.DetailsData + error?: Error + name: string + protocol: 'http' | 'tcp' | 'udp' +} + +export const ServiceDetail = ({ data, error, name, protocol }: ServiceDetailProps) => { + if (error) { + return ( + <> + + {name} - Traefik Proxy + + + Sorry, we could not fetch detail information for this Service right now. Please, try again later. + + + ) + } + + if (!data) { + return ( + <> + + {name} - Traefik Proxy + + + + + + + + + + + + + + + + + + + ) + } + + if (!data.name) { + return + } + + return ( + <> + + {data.name} - Traefik Proxy + +

{data.name}

+ + + + {data.loadBalancer?.healthCheck && } + {!!data?.weighted?.services?.length && ( + + )} + + {!!data?.mirroring?.mirrors && ( + + )} + + + + ) +} diff --git a/webui/src/components/services/ServiceHealthCheck.tsx b/webui/src/components/services/ServiceHealthCheck.tsx new file mode 100644 index 000000000..c9d77f29a --- /dev/null +++ b/webui/src/components/services/ServiceHealthCheck.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react' +import { FiShield } from 'react-icons/fi' + +import CopyableText from 'components/CopyableText' +import { Chips } from 'components/resources/DetailItemComponents' +import DetailsCard from 'components/resources/DetailsCard' + +type ServiceHealthCheckProps = { + data: Service.Details + protocol: 'http' | 'tcp' | 'udp' +} + +const ServiceHealthCheck = ({ data, protocol }: ServiceHealthCheckProps) => { + const isTcp = useMemo(() => protocol === 'tcp', [protocol]) + + const healthCheckItems = useMemo(() => { + if (data.loadBalancer?.healthCheck) { + const healthCheck = data.loadBalancer.healthCheck + if (isTcp) { + return [ + healthCheck?.interval && { key: 'Interval', val: healthCheck.interval }, + healthCheck?.timeout && { key: 'Timeout', val: healthCheck.timeout }, + healthCheck?.port && { key: 'Port', val: healthCheck.port }, + healthCheck?.unhealthyInterval && { key: 'Unhealthy interval', val: healthCheck.unhealthyInterval }, + healthCheck?.send && { + key: 'Send', + val: , + }, + healthCheck?.expect && { + key: 'Expect', + val: , + }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement; stackVertical?: boolean }[] + } else { + return [ + healthCheck?.scheme && { key: 'Scheme', val: healthCheck.scheme }, + healthCheck?.interval && { key: 'Interval', val: healthCheck.interval }, + healthCheck?.path && { + key: 'Path', + val: , + }, + healthCheck?.timeout && { key: 'Timeout', val: healthCheck.timeout }, + healthCheck?.port && { key: 'Port', val: String(healthCheck.port) }, + healthCheck?.hostname && { + key: 'Hostname', + val: , + }, + healthCheck.headers && { + key: 'Headers', + val: entry.join(': '))} />, + stackVertical: true, + forceNewRow: true, + }, + ].filter(Boolean) as { key: string; val: string | React.ReactElement; stackVertical?: boolean }[] + } + } + }, [data.loadBalancer?.healthCheck, isTcp]) + + if (!healthCheckItems) return null + + return ( + } title="Health Check" items={healthCheckItems} testId="health-check" /> + ) +} + +export default ServiceHealthCheck diff --git a/webui/src/components/services/WeightedServices.tsx b/webui/src/components/services/WeightedServices.tsx new file mode 100644 index 000000000..6ddae63b1 --- /dev/null +++ b/webui/src/components/services/WeightedServices.tsx @@ -0,0 +1,42 @@ +import { Flex } from '@traefiklabs/faency' +import { FiGlobe } from 'react-icons/fi' + +import { getProviderFromName } from './utils' + +import ProviderIcon from 'components/icons/providers' +import { SectionTitle } from 'components/resources/DetailsCard' +import PaginatedTable from 'components/tables/PaginatedTable' + +type WeightedServicesProps = { + services: Service.WeightedService[] + defaultProvider: string +} + +const WeightedServices = ({ services, defaultProvider }: WeightedServicesProps) => { + return ( + + } title="Services" /> + ({ + name: service.name, + weight: service.weight, + provider: getProviderFromName(service.name, defaultProvider), + }))} + columns={[ + { key: 'name', header: 'Name' }, + { key: 'weight', header: 'Weight' }, + { key: 'provider', header: 'Provider' }, + ]} + testId="weighted-services" + renderCell={(key, value) => { + if (key === 'provider') { + return + } + return value + }} + /> + + ) +} + +export default WeightedServices diff --git a/webui/src/components/services/utils.ts b/webui/src/components/services/utils.ts new file mode 100644 index 000000000..3dfec9acb --- /dev/null +++ b/webui/src/components/services/utils.ts @@ -0,0 +1,4 @@ +export const getProviderFromName = (serviceName: string, defaultProvider: string): string => { + const [, provider] = serviceName.split('@') + return provider || defaultProvider +} diff --git a/webui/src/components/tables/AriaTableSkeleton.tsx b/webui/src/components/tables/AriaTableSkeleton.tsx new file mode 100644 index 000000000..f9f30d8ed --- /dev/null +++ b/webui/src/components/tables/AriaTableSkeleton.tsx @@ -0,0 +1,79 @@ +import { + AriaTable, + AriaTbody, + AriaTr, + CSS, + AriaTd, + Flex, + Skeleton as FaencySkeleton, + VariantProps, + AriaThead, + AriaTh, +} from '@traefiklabs/faency' +import { ReactNode } from 'react' + +type AriaTableSkeletonProps = { + children?: ReactNode + columns?: number + css?: CSS + lastColumnIsNarrow?: boolean + rowHeight?: string + rows?: number + skeletonWidth?: string +} + +interface AriaTdSkeletonProps extends VariantProps { + css?: CSS + flexCss?: CSS +} +const AriaTdSkeleton = ({ css = {}, flexCss = {} }: AriaTdSkeletonProps) => ( + + + + + +) + +const AriaThSkeleton = ({ css = {}, flexCss = {} }: AriaTdSkeletonProps) => ( + + + + + +) + +export default function AriaTableSkeleton({ + columns = 3, + css, + lastColumnIsNarrow = false, + rowHeight = undefined, + rows = 5, + skeletonWidth = '50%', +}: AriaTableSkeletonProps) { + return ( + + + + {[...Array(columns)].map((_, colIdx) => ( + + ))} + + + + {[...Array(rows)].map((_, rowIdx) => ( + + {[...Array(columns)].map((_, colIdx) => ( + + ))} + + ))} + + + ) +} diff --git a/webui/src/components/ClickableRow.tsx b/webui/src/components/tables/ClickableRow.tsx similarity index 100% rename from webui/src/components/ClickableRow.tsx rename to webui/src/components/tables/ClickableRow.tsx diff --git a/webui/src/components/tables/PaginatedTable.tsx b/webui/src/components/tables/PaginatedTable.tsx new file mode 100644 index 000000000..2d403dfd8 --- /dev/null +++ b/webui/src/components/tables/PaginatedTable.tsx @@ -0,0 +1,153 @@ +import { AriaTable, AriaTbody, AriaTd, AriaThead, AriaTr, Box, Button, Flex, Text } from '@traefiklabs/faency' +import { ReactNode, useEffect, useRef, useState } from 'react' +import { FiChevronLeft, FiChevronRight, FiChevronsLeft, FiChevronsRight } from 'react-icons/fi' + +import SortableTh from './SortableTh' + +type PaginatedTableProps> = { + data: T[] + columns: { + key: keyof T + header: string + sortable?: boolean + width?: string + }[] + itemsPerPage?: number + testId?: string + renderCell?: (key: keyof T, value: T[keyof T], row: T) => ReactNode + renderRow?: (row: T) => ReactNode +} + +const PaginatedTable = >({ + data, + columns, + itemsPerPage = 5, + testId, + renderCell, + renderRow, +}: PaginatedTableProps) => { + const [currentPage, setCurrentPage] = useState(0) + const [tableHeight, setTableHeight] = useState(undefined) + const tableRef = useRef(null) + + const totalPages = Math.ceil(data.length / itemsPerPage) + const startIndex = currentPage * itemsPerPage + const endIndex = startIndex + itemsPerPage + const currentData = data.slice(startIndex, endIndex) + + // Workaround to keep the same height to avoid layout shift + useEffect(() => { + if (totalPages > 1 && currentPage === 0 && tableRef.current && !tableHeight) { + const height = tableRef.current.offsetHeight + setTableHeight(height) + } + }, [totalPages, currentPage, tableHeight]) + + const handleFirstPage = () => { + setCurrentPage(0) + } + + const handlePreviousPage = () => { + setCurrentPage((prev) => Math.max(0, prev - 1)) + } + + const handleNextPage = () => { + setCurrentPage((prev) => Math.min(totalPages - 1, prev + 1)) + } + + const handleLastPage = () => { + setCurrentPage(totalPages - 1) + } + + const getCellContent = (key: keyof T, value: T[keyof T], row: T) => { + if (renderCell) { + return renderCell(key, value, row) + } + return value as ReactNode + } + + return ( + + 1 && tableHeight ? { minHeight: `${tableHeight}px` } : undefined}> + + + {columns.map((column) => ( + + ))} + + + + 1 && tableHeight ? { verticalAlign: 'top' } : undefined}> + {currentData.map((row, rowIndex) => { + if (renderRow) { + return renderRow(row) + } + + const rowContent = ( + <> + {columns?.map((column) => ( + {getCellContent(column.key, row[column.key], row)} + ))} + + ) + + return {rowContent} + })} + + + {totalPages > 1 && ( + + + + + + + Page {currentPage + 1} of {totalPages} + + + + + )} + + ) +} + +export default PaginatedTable diff --git a/webui/src/components/TableFilter.tsx b/webui/src/components/tables/TableFilter.tsx similarity index 100% rename from webui/src/components/TableFilter.tsx rename to webui/src/components/tables/TableFilter.tsx diff --git a/webui/src/hooks/use-href-with-return-to.ts b/webui/src/hooks/use-href-with-return-to.ts index cbf938f7e..8a993e500 100644 --- a/webui/src/hooks/use-href-with-return-to.ts +++ b/webui/src/hooks/use-href-with-return-to.ts @@ -39,7 +39,7 @@ const RETURN_TO_LABEL_OVERRIDES_SINGULAR: Record> }, udp: { routers: 'UDP router', - services: 'TCP service', + services: 'UDP service', }, } @@ -56,7 +56,7 @@ const RETURN_TO_LABEL_OVERRIDES_PLURAL: Record> = }, udp: { routers: 'UDP routers', - services: 'TCP services', + services: 'UDP services', }, } diff --git a/webui/src/hooks/use-resource-detail.tsx b/webui/src/hooks/use-resource-detail.tsx index df6e38941..7a3c7ba09 100644 --- a/webui/src/hooks/use-resource-detail.tsx +++ b/webui/src/hooks/use-resource-detail.tsx @@ -2,115 +2,8 @@ import useSWR from 'swr' import fetchMany from 'libs/fetchMany' -export type EntryPoint = { - name: string - address: string - message?: string -} - -type JSONObject = { - [x: string]: string | number -} -export type ValuesMapType = { - [key: string]: string | number | JSONObject -} - -export type MiddlewareProps = { - [prop: string]: ValuesMapType -} - -export type Middleware = { - name: string - status: 'enabled' | 'disabled' | 'warning' - provider: string - type?: string - plugin?: Record - error?: string[] - routers?: string[] - usedBy?: string[] -} & MiddlewareProps - -type Router = { - name: string - service?: string - status: 'enabled' | 'disabled' | 'warning' - rule?: string - priority?: number - provider: string - tls?: { - options: string - certResolver: string - domains: TlsDomain[] - passthrough: boolean - } - error?: string[] - entryPoints?: string[] - message?: string -} - -type TlsDomain = { - main: string - sans: string[] -} - -export type RouterDetailType = Router & { - middlewares?: Middleware[] - hasValidMiddlewares?: boolean - entryPointsData?: EntryPoint[] - using?: string[] -} - -type Mirror = { - name: string - percent: number -} - -export type ServiceDetailType = { - name: string - status: 'enabled' | 'disabled' | 'warning' - provider: string - type: string - usedBy?: string[] - routers?: Router[] - serverStatus?: { - [server: string]: string - } - mirroring?: { - service: string - mirrors?: Mirror[] - } - loadBalancer?: { - servers?: { url: string }[] - passHostHeader?: boolean - terminationDelay?: number - healthCheck?: { - scheme: string - path: string - port: number - interval: string - timeout: string - hostname: string - headers?: { - [header: string]: string - } - } - } - weighted?: { - services?: { - name: string - weight: number - }[] - } -} - -export type MiddlewareDetailType = Middleware & { - routers?: Router[] -} - -export type ResourceDetailDataType = RouterDetailType & ServiceDetailType & MiddlewareDetailType - type ResourceDetailType = { - data?: ResourceDetailDataType + data?: Resource.DetailsData error?: Error } @@ -128,7 +21,7 @@ export const useResourceDetail = (name: string, resource: string, protocol = 'ht } const firstError = error || entryPointsError || middlewaresError || routersError - const validMiddlewares = (middlewares as Middleware[] | undefined)?.filter((mw) => !!mw.name) + const validMiddlewares = (middlewares as Middleware.Details[] | undefined)?.filter((mw) => !!mw.name) const hasMiddlewares = validMiddlewares ? validMiddlewares.length > 0 : routeDetail.middlewares && routeDetail.middlewares.length > 0 diff --git a/webui/src/layout/navigation/TopNavBar.tsx b/webui/src/layout/navigation/TopNavBar.tsx index e31092098..902573d0f 100644 --- a/webui/src/layout/navigation/TopNavBar.tsx +++ b/webui/src/layout/navigation/TopNavBar.tsx @@ -13,7 +13,8 @@ import { Text, Tooltip, } from '@traefiklabs/faency' -import { useContext, useEffect, useMemo, useState } from 'react' +import { useContext, useMemo } from 'react' +import { Helmet } from 'react-helmet-async' import { FiBookOpen, FiChevronLeft, FiGithub, FiHeart, FiHelpCircle } from 'react-icons/fi' import { useLocation } from 'react-router-dom' @@ -22,6 +23,7 @@ import { DARK_PRIMARY_COLOR, LIGHT_PRIMARY_COLOR } from '../Page' import ThemeSwitcher from 'components/ThemeSwitcher' import { VersionContext } from 'contexts/version' import { useRouterReturnTo } from 'hooks/use-href-with-return-to' +import useHubUpgradeButton from 'hooks/use-hub-upgrade-button' import { useIsDarkMode } from 'hooks/use-theme' const TopNavBarBackLink = () => { @@ -43,8 +45,7 @@ const TopNavBarBackLink = () => { } export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => { - const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false) - const { showHubButton, version } = useContext(VersionContext) + const { version } = useContext(VersionContext) const isDarkMode = useIsDarkMode() const parsedVersion = useMemo(() => { @@ -58,101 +59,86 @@ export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: return matches ? 'v' + matches[1] : 'master' }, [version]) - useEffect(() => { - if (!showHubButton) { - setHasHubButtonComponent(false) - return - } + const { signatureVerified, scriptBlobUrl, isCustomElementDefined } = useHubUpgradeButton() - if (customElements.get('hub-button-app')) { - setHasHubButtonComponent(true) - return - } - - const scripts: HTMLScriptElement[] = [] - const createScript = (scriptSrc: string): HTMLScriptElement => { - const script = document.createElement('script') - script.src = scriptSrc - script.async = true - script.onload = () => { - setHasHubButtonComponent(customElements.get('hub-button-app') !== undefined) - } - scripts.push(script) - return script - } - - // Source: https://github.com/traefik/traefiklabs-hub-button-app - document.head.appendChild(createScript('traefiklabs-hub-button-app/main-v1.js')) - - return () => { - // Remove the scripts on unmount. - scripts.forEach((script) => { - if (script.parentNode) { - script.parentNode.removeChild(script) - } - }) - } - }, [showHubButton]) + const displayUpgradeToHubButton = useMemo( + () => !noHubButton && signatureVerified && (!!scriptBlobUrl || isCustomElementDefined), + [isCustomElementDefined, noHubButton, scriptBlobUrl, signatureVerified], + ) return ( - - - - {!noHubButton && hasHubButtonComponent && ( - - - - )} - - - - - - + <> + {displayUpgradeToHubButton && ( + + + + + )} + + + + {displayUpgradeToHubButton && ( + + + + )} + + + + + + - - - - - - - - - - - - Documentation - - - - - - - - Github Repository - - - - - - - + + + + + + + + + + + + Documentation + + + + + + + + Github Repository + + + + + + + + - + ) } diff --git a/webui/src/libs/objectHandlers.ts b/webui/src/libs/objectHandlers.ts deleted file mode 100644 index 59c3bcf0c..000000000 --- a/webui/src/libs/objectHandlers.ts +++ /dev/null @@ -1,8 +0,0 @@ -type ObjectWithMessage = { - message?: string -} - -export const getValidData = (data?: T[]): T[] => - data ? data.filter((item) => !item.message) : [] -export const getErrorData = (data?: T[]): T[] => - data ? data.filter((item) => !!item.message) : [] diff --git a/webui/src/libs/parsers.ts b/webui/src/libs/parsers.ts index 26f2ed107..46922f217 100644 --- a/webui/src/libs/parsers.ts +++ b/webui/src/libs/parsers.ts @@ -1,6 +1,4 @@ -import { Middleware } from 'hooks/use-resource-detail' - -export const parseMiddlewareType = (middleware: Middleware): string | undefined => { +export const parseMiddlewareType = (middleware: Middleware.Props): string | undefined => { if (middleware.plugin) { const pluginObject = middleware.plugin || {} const [pluginName] = Object.keys(pluginObject) diff --git a/webui/src/mocks/data/api-entrypoints.json b/webui/src/mocks/data/api-entrypoints.json index 456477530..b91f638ca 100644 --- a/webui/src/mocks/data/api-entrypoints.json +++ b/webui/src/mocks/data/api-entrypoints.json @@ -63,6 +63,19 @@ }, "forwardedHeaders": {}, "name": "web-secured" + }, + { + "address": ":443", + "transport": { + "lifeCycle": { + "graceTimeOut": 10000000000 + }, + "respondingTimeouts": { + "idleTimeout": 180000000000 + } + }, + "forwardedHeaders": {}, + "name": "web-secured-longer-name" }, { "address": ":8100", diff --git a/webui/src/mocks/data/api-http_middlewares.json b/webui/src/mocks/data/api-http_middlewares.json index a8737d28e..23433f20c 100644 --- a/webui/src/mocks/data/api-http_middlewares.json +++ b/webui/src/mocks/data/api-http_middlewares.json @@ -1,13 +1,24 @@ [ { - "addPrefix": { - "prefix": "/foo" - }, "status": "enabled", "usedBy": ["web@docker"], "name": "add-foo@docker", "type": "addprefix", - "provider": "docker" + "provider": "docker", + "addPrefix": { + "prefix": "/path", + "aCustomObject": { + "array of arrays": [ + [1, 2], + [3, 4] + ], + "array of objects": [{ "some": "value" }, { "another": "value" }], + "array of booleans": [true, false, true], + "array of numbers": [10, 100, 1000], + "array of strings": ["value1", "value2"] + } + }, + "error": ["message 1", "message 2"] }, { "redirectScheme": { @@ -55,14 +66,21 @@ "addPrefix": { "prefix": "/path", "aCustomObject": { - "array of arrays": [[1, 2], [3, 4]], + "array of arrays": [ + [1, 2], + [3, 4] + ], "array of objects": [{ "some": "value" }, { "another": "value" }], "array of booleans": [true, false, true], "array of numbers": [10, 100, 1000], "array of strings": ["value1", "value2"] } }, - "error": ["message 1", "message 2"], + "error": [ + "message 1", + "message 2", + "('colorByStatus' export is incompatible). Learn more at https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#consistent-components-exports" + ], "status": "enabled", "usedBy": ["foo@docker", "bar@file"], "name": "middleware00@docker", @@ -144,7 +162,10 @@ }, { "basicAuth": { - "users": ["test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP4HxwgUir3HP4EsggP/QNo0"], + "users": [ + "test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/", + "test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP4HxwgUir3HP4EsggP/QNo0" + ], "usersFile": "/etc/foo/my/file/path/.htpasswd", "realm": "Hello you are here", "removeHeader": true, diff --git a/webui/src/mocks/data/api-http_routers.json b/webui/src/mocks/data/api-http_routers.json index 227aa7cc3..5e81954de 100644 --- a/webui/src/mocks/data/api-http_routers.json +++ b/webui/src/mocks/data/api-http_routers.json @@ -4,10 +4,7 @@ "rule": "Host(`jaeger-v2-example-beta1`)", "status": "enabled", "name": "jaeger_v2-example-beta1@docker", - "using": [ - "web-secured", - "web" - ], + "using": ["web-secured", "web"], "priority": 10, "provider": "docker" }, @@ -20,6 +17,24 @@ ], "status": "disabled", "name": "orphan-router@file", + "tls": { + "options": "foo@file", + "certResolver": "acme-dns-challenge", + "domains": [ + { + "main": "example.com", + "sans": ["foo.example.com", "bar.example.com"] + }, + { + "main": "domain.com", + "sans": ["foo.domain.com", "bar.domain.com"] + }, + { + "main": "my.domain.com", + "sans": ["foo.my.domain.com", "bar.my.domain.com"] + } + ] + }, "middlewares": [ "middleware00@docker", "middleware01@docker", @@ -43,20 +58,12 @@ "middleware19@docker", "middleware20@docker" ], - "using": [ - "web-secured", - "web", - "traefik", - "web2", - "web3" - ], + "using": ["web-secured", "web", "traefik", "web2", "web3"], "priority": 30, "provider": "file" }, { - "entryPoints": [ - "web-mtls" - ], + "entryPoints": ["web-mtls"], "service": "api3_v2-example-beta1", "rule": "Host(`server`) \u0026\u0026 Path(`/mtls`)", "tls": { @@ -65,24 +72,15 @@ "domains": [ { "main": "example.com", - "sans": [ - "foo.example.com", - "bar.example.com" - ] + "sans": ["foo.example.com", "bar.example.com"] }, { "main": "domain.com", - "sans": [ - "foo.domain.com", - "bar.domain.com" - ] + "sans": ["foo.domain.com", "bar.domain.com"] }, { "main": "my.domain.com", - "sans": [ - "foo.my.domain.com", - "bar.my.domain.com" - ] + "sans": ["foo.my.domain.com", "bar.my.domain.com"] } ] }, @@ -90,39 +88,27 @@ "priority": 42, "name": "server-mtls@docker", "provider": "docker", - "using": [ - "web-mtls" - ] + "using": ["web-mtls"] }, { - "entryPoints": [ - "web-redirect" - ], - "middlewares": [ - "redirect@file" - ], + "entryPoints": ["web-redirect"], + "middlewares": ["redirect@file"], "service": "api2_v2-example-beta1", "rule": "Host(`server`)", "status": "enabled", "name": "server-redirect@docker", - "using": [ - "web-redirect" - ], + "using": ["web-redirect"], "priority": 9223372036854776000, "provider": "docker" }, { - "entryPoints": [ - "web-secured" - ], + "entryPoints": ["web-secured"], "service": "api2_v2-example-beta1", "rule": "Host(`server`)", "tls": {}, "status": "enabled", "name": "server-secured@docker", - "using": [ - "web-secured" - ], + "using": ["web-secured"], "provider": "docker" }, { @@ -130,42 +116,27 @@ "rule": "Host(`traefik-v2-example-beta1`)", "status": "enabled", "name": "traefik_v2-example-beta1@docker", - "using": [ - "web-secured", - "web" - ], + "using": ["web-secured", "web"], "provider": "docker" }, { - "entryPoints": [ - "web" - ], - "middlewares": [ - "add-foo" - ], + "entryPoints": ["web"], + "middlewares": ["add-foo"], "service": "api_v2-example-beta1", "rule": "Host(`jorge.dockeree.containous.cloud`)", "status": "enabled", "name": "web@docker", - "using": [ - "web" - ], + "using": ["web"], "provider": "docker" }, { - "entryPoints": [ - "web" - ], - "middlewares": [ - "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware" - ], + "entryPoints": ["web"], + "middlewares": ["whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service-middleware"], "service": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a-service", "rule": "Host(`jorge.dockeree.containous.cloud`)", "status": "enabled", "name": "whoami-app-hello-tls-jwt-ef36e528ebdc93bc4f2a@kubernetescrd", - "using": [ - "web" - ], + "using": ["web"], "provider": "docker" } ] diff --git a/webui/src/mocks/data/api-http_services.json b/webui/src/mocks/data/api-http_services.json index 41edc7076..6a139ed17 100644 --- a/webui/src/mocks/data/api-http_services.json +++ b/webui/src/mocks/data/api-http_services.json @@ -9,10 +9,7 @@ "passHostHeader": true }, "status": "enabled", - "usedBy": [ - "server-redirect@docker", - "server-secured@docker" - ], + "usedBy": ["server-redirect@docker", "server-secured@docker"], "serverStatus": { "http://10.0.1.12:80": "UP" }, @@ -95,9 +92,7 @@ } }, "status": "enabled", - "usedBy": [ - "server-mtls@docker" - ], + "usedBy": ["server-mtls@docker"], "serverStatus": { "http://10.0.1.20:80": "UP", "http://10.0.1.21:80": "UP", @@ -107,8 +102,25 @@ "http://10.0.1.25:80": "UP" }, "name": "api3_v2-example-beta1@docker", - "type": "loadbalancer", - "provider": "docker" + "type": "mirroring", + "provider": "docker", + "mirroring": { + "mirrors": [ + { + "name": "two@docker", + "percent": 10 + }, + { + "name": "three@docker", + "percent": 15 + }, + { + "name": "four@docker", + "percent": 80 + } + ], + "service": "one@docker" + } }, { "loadBalancer": { @@ -120,9 +132,7 @@ "passHostHeader": true }, "status": "enabled", - "usedBy": [ - "web@docker" - ], + "usedBy": ["web@docker"], "serverStatus": { "http://10.0.1.11:80": "UP" }, @@ -140,9 +150,7 @@ "passHostHeader": true }, "status": "enabled", - "usedBy": [ - "jaeger_v2-example-beta1@docker" - ], + "usedBy": ["jaeger_v2-example-beta1@docker"], "serverStatus": { "http://10.0.1.20:5775": "UP" }, @@ -174,9 +182,7 @@ "passHostHeader": true }, "status": "enabled", - "usedBy": [ - "traefik_v2-example-beta1@docker" - ], + "usedBy": ["traefik_v2-example-beta1@docker"], "serverStatus": { "http://10.0.1.10:80": "UP" }, @@ -189,9 +195,7 @@ "provider": "docker", "status": "enabled", "type": "weighted", - "usedBy": [ - "foo@docker" - ], + "usedBy": ["foo@docker"], "weighted": { "sticky": { "cookie": { @@ -207,9 +211,7 @@ "provider": "docker", "status": "enabled", "type": "weighted", - "usedBy": [ - "fii@docker" - ], + "usedBy": ["fii@docker"], "weighted": { "sticky": { "cookie": {} @@ -238,8 +240,6 @@ "provider": "docker", "status": "enabled", "type": "mirroring", - "usedBy": [ - "foo@docker" - ] + "usedBy": ["foo@docker"] } ] diff --git a/webui/src/mocks/data/api-version.json b/webui/src/mocks/data/api-version.json index d4577bdbf..4923a8856 100644 --- a/webui/src/mocks/data/api-version.json +++ b/webui/src/mocks/data/api-version.json @@ -1,6 +1,6 @@ { - "Version": "3.4.0", - "Codename": "montdor", + "Version": "3.6.0", + "Codename": "ramequin", "disableDashboardAd": false, "startDate": "2025-03-28T14:58:25.8937758+01:00" } \ No newline at end of file diff --git a/webui/src/pages/http/HttpMiddleware.spec.tsx b/webui/src/pages/http/HttpMiddleware.spec.tsx index 8bf003735..e70efbd62 100644 --- a/webui/src/pages/http/HttpMiddleware.spec.tsx +++ b/webui/src/pages/http/HttpMiddleware.spec.tsx @@ -1,12 +1,10 @@ -import { HttpMiddlewareRender } from './HttpMiddleware' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -55,7 +53,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/middlewares/middleware-simple', withPage: true }, ) @@ -67,12 +65,11 @@ describe('', () => { expect(middlewareCard.innerHTML).toContain('addprefix') expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy() expect(middlewareCard.innerHTML).toContain('Success') - expect(middlewareCard.innerHTML).toContain('/foo') + expect(container.innerHTML).toContain('/foo') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test-simple@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test-simple@docker') }) it('should render a plugin middleware', () => { @@ -102,7 +99,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/middlewares/middleware-plugin', withPage: true }, ) @@ -112,11 +109,11 @@ describe('', () => { const middlewareCard = getByTestId('middleware-card') expect(middlewareCard.innerHTML).toContain('jwtAuth') + expect(middlewareCard.innerHTML).toContain('Success') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test-plugin@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test-plugin@docker') }) it('should render a complex middleware', async () => { @@ -342,7 +339,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/middlewares/middleware-complex', withPage: true }, ) @@ -353,60 +350,50 @@ describe('', () => { const middlewareCard = getByTestId('middleware-card') expect(middlewareCard.innerHTML).toContain('Success') expect(middlewareCard.innerHTML).toContain('the-provider') - expect(middlewareCard.innerHTML).toContain('redirect-scheme') - expect(middlewareCard.innerHTML).toContain('add-prefix-sample') - expect(middlewareCard.innerHTML).toContain('buffer-retry-expression') - expect(middlewareCard.innerHTML).toContain('circuit-breaker') - expect(middlewareCard.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['126.0.0.1', '126.0.0.2', 'inflight-req-header']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['125.0.0.1', '125.0.0.2', '125.0.0.3', '125.0.0.4']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['ssl.host', 'ssl-proxy-header-a', 'ssl-proxy-header-b']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2']) - expect(middlewareCard.innerHTML).toContain('allowed.origin') - expect(middlewareCard.innerHTML).toContain('custom-frame-options') - expect(middlewareCard.innerHTML).toContain('content-security-policy') - expect(middlewareCard.innerHTML).toContain('public-key') - expect(middlewareCard.innerHTML).toContain('referrer-policy') - expect(middlewareCard.innerHTML).toContain('feature-policy') - expect(middlewareCard.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b']) - expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b']) - expect(middlewareCard.innerHTML).toIncludeMultiple([ + expect(container.innerHTML).toContain('redirect-scheme') + expect(container.innerHTML).toContain('add-prefix-sample') + expect(container.innerHTML).toContain('buffer-retry-expression') + expect(container.innerHTML).toContain('circuit-breaker') + expect(container.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement']) + expect(container.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to']) + expect(container.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header']) + expect(container.innerHTML).toIncludeMultiple(['126.0.0.1', '126.0.0.2', 'inflight-req-header']) + expect(container.innerHTML).toIncludeMultiple(['125.0.0.1', '125.0.0.2', '125.0.0.3', '125.0.0.4']) + expect(container.innerHTML).toIncludeMultiple(['ssl.host', 'ssl-proxy-header-a', 'ssl-proxy-header-b']) + expect(container.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b']) + expect(container.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2']) + expect(container.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2']) + expect(container.innerHTML).toContain('allowed.origin') + expect(container.innerHTML).toContain('custom-frame-options') + expect(container.innerHTML).toContain('content-security-policy') + expect(container.innerHTML).toContain('public-key') + expect(container.innerHTML).toContain('referrer-policy') + expect(container.innerHTML).toContain('feature-policy') + expect(container.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT']) + expect(container.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2']) + expect(container.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b']) + expect(container.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b']) + expect(container.innerHTML).toIncludeMultiple([ 'forward-auth-address', 'auth-response-header-1', 'auth-response-header-2', ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ + expect(container.innerHTML).toIncludeMultiple([ 'error-sample', 'status-1', 'status-2', 'errors-service', 'errors-query', ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ - 'chain-middleware-1', - 'chain-middleware-2', - 'chain-middleware-3', - ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ - 'user1', - 'user2', - 'users/file', - 'realm-sample', - 'basic-auth-header', - ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ + expect(container.innerHTML).toIncludeMultiple(['chain-middleware-1', 'chain-middleware-2', 'chain-middleware-3']) + expect(container.innerHTML).toIncludeMultiple(['user1', 'user2', 'users/file', 'realm-sample', 'basic-auth-header']) + expect(container.innerHTML).toIncludeMultiple([ 'strip-prefix1', 'strip-prefix2', 'strip-prefix-regex1', 'strip-prefix-regex2', ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ + expect(container.innerHTML).toIncludeMultiple([ '10000', '10001', '10002', @@ -421,7 +408,7 @@ describe('', () => { '10011', '10012', ]) - expect(middlewareCard.innerHTML).toIncludeMultiple([ + expect(container.innerHTML).toIncludeMultiple([ 'plugin-ldap-source', 'plugin-ldap-base-dn', 'plugin-ldap-attribute', @@ -438,9 +425,8 @@ describe('', () => { ]) const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test-complex@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test-complex@docker') }) it('should render a plugin middleware with no type', async () => { @@ -464,7 +450,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/middlewares/middleware-plugin-no-type', withPage: true }, ) @@ -474,15 +460,15 @@ describe('', () => { const middlewareCard = getByTestId('middleware-card') expect(middlewareCard.innerHTML).toContain('Success') - expect(middlewareCard.innerHTML).toContain('jwtAuth > child') - expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > negative Grand Child') - expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > positive Grand Child') - expect(middlewareCard.innerHTML).toContain('jwtAuth > string Child') - expect(middlewareCard.innerHTML).toContain('jwtAuth > array Child') + expect(container.innerHTML).toContain('jwtAuth > child') + expect(container.innerHTML).toContain('jwtAuth > sibling > negative Grand Child') + expect(container.innerHTML).toContain('jwtAuth > sibling > positive Grand Child') + expect(container.innerHTML).toContain('jwtAuth > string Child') + expect(container.innerHTML).toContain('jwtAuth > array Child') - const childSpans = Array.from(middlewareCard.querySelectorAll('span')).filter((span) => + const childSpans = Array.from(container.querySelectorAll('span')).filter((span) => ['0', '1', '2', '3', '123'].includes(span.innerHTML), ) - expect(childSpans.length).toBe(7) + expect(childSpans.length).toBe(6) }) }) diff --git a/webui/src/pages/http/HttpMiddleware.tsx b/webui/src/pages/http/HttpMiddleware.tsx index d762e6975..511ac190d 100644 --- a/webui/src/pages/http/HttpMiddleware.tsx +++ b/webui/src/pages/http/HttpMiddleware.tsx @@ -1,82 +1,12 @@ -import { Box, Card, H1, Skeleton, styled, Text } from '@traefiklabs/faency' -import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' -import { DetailSectionSkeleton } from 'components/resources/DetailSections' -import { RenderMiddleware } from 'components/resources/MiddlewarePanel' -import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' -import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail' -import { NotFound } from 'pages/NotFound' -import breakpoints from 'utils/breakpoints' - -const MiddlewareGrid = styled(Box, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', - - [`@media (max-width: ${breakpoints.tablet})`]: { - gridTemplateColumns: '1fr', - }, -}) - -type HttpMiddlewareRenderProps = { - data?: ResourceDetailDataType - error?: Error | null - name: string -} - -export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Middleware right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - -

{data.name}

- - - - - - - - ) -} +import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const HttpMiddleware = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'middlewares') - return + return } export default HttpMiddleware diff --git a/webui/src/pages/http/HttpMiddlewares.tsx b/webui/src/pages/http/HttpMiddlewares.tsx index 4ec86d841..5df1633ee 100644 --- a/webui/src/pages/http/HttpMiddlewares.tsx +++ b/webui/src/pages/http/HttpMiddlewares.tsx @@ -1,17 +1,16 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => { return ( - - - - - + @@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => { - - - - - + ) @@ -69,7 +60,7 @@ export const HttpMiddlewaresRender = ({ - + diff --git a/webui/src/pages/http/HttpRouter.spec.tsx b/webui/src/pages/http/HttpRouter.spec.tsx index ca90455d9..3a0690232 100644 --- a/webui/src/pages/http/HttpRouter.spec.tsx +++ b/webui/src/pages/http/HttpRouter.spec.tsx @@ -1,6 +1,4 @@ -import { HttpRouterRender } from './HttpRouter' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { RouterDetail } from 'components/routers/RouterDetail' import apiEntrypoints from 'mocks/data/api-entrypoints.json' import apiHttpMiddlewares from 'mocks/data/api-http_middlewares.json' import apiHttpRouters from 'mocks/data/api-http_routers.json' @@ -9,7 +7,7 @@ import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -17,7 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -25,7 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -42,7 +40,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/routers/orphan-router@file', withPage: true }, ) @@ -52,7 +50,6 @@ describe('', () => { expect(routerStructure.innerHTML).toContain(':8080') expect(routerStructure.innerHTML).toContain(':8002') expect(routerStructure.innerHTML).toContain(':8003') - expect(routerStructure.innerHTML).toContain('orphan-router@file') expect(routerStructure.innerHTML).toContain('middleware00') expect(routerStructure.innerHTML).toContain('middleware01') expect(routerStructure.innerHTML).toContain('middleware02') @@ -78,43 +75,35 @@ describe('', () => { expect(routerStructure.innerHTML).toContain('HTTP Router') expect(routerStructure.innerHTML).not.toContain('TCP Router') - const routerDetailsSection = getByTestId('router-detail') + const routerDetailsSection = getByTestId('router-details') - const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)') - expect(routerDetailsPanel?.innerHTML).toContain('orphan-router@file') - expect(routerDetailsPanel?.innerHTML).toContain('Error') - expect(routerDetailsPanel?.querySelector('svg[data-testid="file"]')).toBeTruthy() - expect(routerDetailsPanel?.innerHTML).toContain( + expect(routerDetailsSection?.innerHTML).toContain('Error') + expect(routerDetailsSection?.querySelector('svg[data-testid="file"]')).toBeTruthy() + expect(routerDetailsSection?.innerHTML).toContain( 'Path(`somethingreallyunexpectedbutalsoverylongitgetsoutofthecontainermaybe`)', ) - expect(routerDetailsPanel?.innerHTML).toContain('unexistingservice') - expect(routerDetailsPanel?.innerHTML).toContain('the service "unexistingservice@file" does not exist') - const middlewaresPanel = routerDetailsSection.querySelector(':scope > div:nth-child(3)') - const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || []) - expect(middlewaresPanel?.innerHTML).toContain('middleware00') - expect(middlewaresPanel?.innerHTML).toContain('middleware01') - expect(middlewaresPanel?.innerHTML).toContain('middleware02') - expect(middlewaresPanel?.innerHTML).toContain('middleware03') - expect(middlewaresPanel?.innerHTML).toContain('middleware04') - expect(middlewaresPanel?.innerHTML).toContain('middleware05') - expect(middlewaresPanel?.innerHTML).toContain('middleware06') - expect(middlewaresPanel?.innerHTML).toContain('middleware07') - expect(middlewaresPanel?.innerHTML).toContain('middleware08') - expect(middlewaresPanel?.innerHTML).toContain('middleware09') - expect(middlewaresPanel?.innerHTML).toContain('middleware10') - expect(middlewaresPanel?.innerHTML).toContain('middleware11') - expect(middlewaresPanel?.innerHTML).toContain('middleware12') - expect(middlewaresPanel?.innerHTML).toContain('middleware13') - expect(middlewaresPanel?.innerHTML).toContain('middleware14') - expect(middlewaresPanel?.innerHTML).toContain('middleware15') - expect(middlewaresPanel?.innerHTML).toContain('middleware16') - expect(middlewaresPanel?.innerHTML).toContain('middleware17') - expect(middlewaresPanel?.innerHTML).toContain('middleware18') - expect(middlewaresPanel?.innerHTML).toContain('middleware19') - expect(middlewaresPanel?.innerHTML).toContain('middleware20') - expect(middlewaresPanel?.innerHTML).toContain('Success') - expect(providers.length).toBe(21) + expect(routerStructure.innerHTML).toContain('middleware00') + expect(routerStructure.innerHTML).toContain('middleware01') + expect(routerStructure.innerHTML).toContain('middleware02') + expect(routerStructure.innerHTML).toContain('middleware03') + expect(routerStructure.innerHTML).toContain('middleware04') + expect(routerStructure.innerHTML).toContain('middleware05') + expect(routerStructure.innerHTML).toContain('middleware06') + expect(routerStructure.innerHTML).toContain('middleware07') + expect(routerStructure.innerHTML).toContain('middleware08') + expect(routerStructure.innerHTML).toContain('middleware09') + expect(routerStructure.innerHTML).toContain('middleware10') + expect(routerStructure.innerHTML).toContain('middleware11') + expect(routerStructure.innerHTML).toContain('middleware12') + expect(routerStructure.innerHTML).toContain('middleware13') + expect(routerStructure.innerHTML).toContain('middleware14') + expect(routerStructure.innerHTML).toContain('middleware15') + expect(routerStructure.innerHTML).toContain('middleware16') + expect(routerStructure.innerHTML).toContain('middleware17') + expect(routerStructure.innerHTML).toContain('middleware18') + expect(routerStructure.innerHTML).toContain('middleware19') + expect(routerStructure.innerHTML).toContain('middleware20') expect(getByTestId('/http/middlewares/middleware00@docker')).toBeInTheDocument() diff --git a/webui/src/pages/http/HttpRouter.tsx b/webui/src/pages/http/HttpRouter.tsx index 62801083a..dc4b8de63 100644 --- a/webui/src/pages/http/HttpRouter.tsx +++ b/webui/src/pages/http/HttpRouter.tsx @@ -1,161 +1,13 @@ -import { Flex, styled, Text } from '@traefiklabs/faency' -import { useContext, useEffect, useMemo } from 'react' -import { Helmet } from 'react-helmet-async' -import { FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi' import { useParams } from 'react-router-dom' -import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections' -import MiddlewarePanel from 'components/resources/MiddlewarePanel' -import RouterPanel from 'components/resources/RouterPanel' -import TlsPanel from 'components/resources/TlsPanel' -import { ToastContext } from 'contexts/toasts' -import { EntryPoint, ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail' -import { getErrorData, getValidData } from 'libs/objectHandlers' -import { parseMiddlewareType } from 'libs/parsers' -import { NotFound } from 'pages/NotFound' - -const CardListColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(4, 1fr)', - marginBottom: '48px', -}) - -type DetailProps = { - data: ResourceDetailDataType - protocol?: string -} - -export const RouterStructure = ({ data, protocol = 'http' }: DetailProps) => { - const { addToast } = useContext(ToastContext) - const entrypoints = useMemo(() => getValidData(data.entryPointsData), [data?.entryPointsData]) - const entrypointsError = useMemo(() => getErrorData(data.entryPointsData), [data?.entryPointsData]) - - const serviceSlug = data.service?.includes('@') - ? data.service - : `${data.service ?? 'unknown'}@${data.provider ?? 'unknown'}` - - useEffect(() => { - entrypointsError?.map((error) => - addToast({ - message: error.message, - severity: 'error', - }), - ) - }, [addToast, entrypointsError]) - - return ( - - {entrypoints.length > 0 && ( - } - title="Entrypoints" - cards={data.entryPointsData?.map((ep: EntryPoint) => ({ - title: ep.name, - description: ep.address, - }))} - /> - )} - } - title={`${protocol.toUpperCase()} Router`} - cards={[{ title: 'router', description: data.name, focus: true }]} - /> - {data.hasValidMiddlewares && ( - } - title={`${protocol.toUpperCase()} Middlewares`} - cards={data.middlewares?.map((mw) => ({ - title: parseMiddlewareType(mw) ?? 'middleware', - description: mw.name, - link: `/${protocol}/middlewares/${mw.name}`, - }))} - /> - )} - } - title="Service" - cards={[{ title: 'service', description: data.service, link: `/${protocol}/services/${serviceSlug}` }]} - /> - - ) -} - -const SpacedColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', - gridGap: '16px', -}) - -const RouterDetail = ({ data }: DetailProps) => ( - - - - - -) - -type HttpRouterRenderProps = { - data?: ResourceDetailDataType - error?: Error | null - name: string -} - -export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Router right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - - - - - ) -} +import { RouterDetail } from 'components/routers/RouterDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const HttpRouter = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'routers') - return + + return } export default HttpRouter diff --git a/webui/src/pages/http/HttpRouters.tsx b/webui/src/pages/http/HttpRouters.tsx index 7896ca38a..ed84df5a3 100644 --- a/webui/src/pages/http/HttpRouters.tsx +++ b/webui/src/pages/http/HttpRouters.tsx @@ -1,18 +1,18 @@ import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' -import { FiShield } from 'react-icons/fi' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' -import { Chips } from 'components/resources/DetailSections' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' +import { Chips } from 'components/resources/DetailItemComponents' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' +import TlsIcon from 'components/routers/TlsIcon' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import Tooltip from 'components/Tooltip' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' @@ -22,25 +22,32 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => { const HttpRoutersRenderRow = (row) => ( - - - - - + {protocol !== 'udp' && ( <> {row.tls && ( - - + + )} - + )} @@ -52,11 +59,7 @@ export const makeRowRender = (protocol = 'http'): RenderRowType => { - - - - - + @@ -86,8 +89,8 @@ export const HttpRoutersRender = ({ - - + + diff --git a/webui/src/pages/http/HttpService.spec.tsx b/webui/src/pages/http/HttpService.spec.tsx index 76059e21d..d4b98500a 100644 --- a/webui/src/pages/http/HttpService.spec.tsx +++ b/webui/src/pages/http/HttpService.spec.tsx @@ -1,12 +1,10 @@ -import { HttpServiceRender } from './HttpService' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { ServiceDetail } from 'components/services/ServiceDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -73,7 +71,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/services/mock-service', withPage: true }, ) @@ -88,7 +86,7 @@ describe('', () => { expect(serviceDetails.innerHTML).toContain('docker') expect(serviceDetails.innerHTML).toContain('Status') expect(serviceDetails.innerHTML).toContain('Success') - expect(serviceDetails.innerHTML).toContain('Pass Host Header') + expect(serviceDetails.innerHTML).toContain('Pass host header') expect(serviceDetails.innerHTML).toContain('True') const serversList = getByTestId('servers-list') @@ -96,10 +94,9 @@ describe('', () => { expect(serversList.innerHTML).toContain('http://10.0.1.12:80') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2) - expect(tableBody?.innerHTML).toContain('router-test1@docker') - expect(tableBody?.innerHTML).toContain('router-test2@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2) + expect(routersTable.innerHTML).toContain('router-test1@docker') + expect(routersTable.innerHTML).toContain('router-test2@docker') expect(() => { getByTestId('health-check') @@ -145,7 +142,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/services/mock-service', withPage: true }, ) @@ -200,7 +197,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/http/services/mock-service', withPage: true }, ) diff --git a/webui/src/pages/http/HttpService.tsx b/webui/src/pages/http/HttpService.tsx index 1761cbf05..aac9cb13d 100644 --- a/webui/src/pages/http/HttpService.tsx +++ b/webui/src/pages/http/HttpService.tsx @@ -1,323 +1,13 @@ -import { Badge, Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency' -import { useMemo } from 'react' -import { Helmet } from 'react-helmet-async' -import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi' import { useParams } from 'react-router-dom' -import ProviderIcon from 'components/icons/providers' -import { - BooleanState, - Chips, - DetailSection, - DetailSectionSkeleton, - ItemBlock, - ItemTitle, - LayoutTwoCols, - ProviderName, -} from 'components/resources/DetailSections' -import { ResourceStatus } from 'components/resources/ResourceStatus' -import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' -import Tooltip from 'components/Tooltip' -import { ResourceDetailDataType, ServiceDetailType, useResourceDetail } from 'hooks/use-resource-detail' -import { NotFound } from 'pages/NotFound' - -type DetailProps = { - data: ServiceDetailType - protocol?: string -} - -const SpacedColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', - gridGap: '16px', -}) - -const ServicesGrid = styled(Box, { - display: 'grid', - gridTemplateColumns: '2fr 1fr 1fr', - alignItems: 'center', - padding: '$3 $5', - borderBottom: '1px solid $tableRowBorder', -}) - -const ServersGrid = styled(Box, { - display: 'grid', - alignItems: 'center', - padding: '$3 $5', - borderBottom: '1px solid $tableRowBorder', -}) - -const MirrorsGrid = styled(Box, { - display: 'grid', - gridTemplateColumns: '2fr 1fr 1fr', - alignItems: 'center', - padding: '$3 $5', - borderBottom: '1px solid $tableRowBorder', - - '> *:not(:first-child)': { - justifySelf: 'flex-end', - }, -}) - -const GridTitle = styled(Text, { - fontSize: '14px', - fontWeight: 700, - color: 'hsl(0, 0%, 56%)', -}) - -type Server = { - url: string - address?: string -} - -type ServerStatus = { - [server: string]: string -} - -function getServerStatusList(data: ServiceDetailType): ServerStatus { - const serversList: ServerStatus = {} - - data.loadBalancer?.servers?.forEach((server: Server) => { - serversList[server.address || server.url] = 'DOWN' - }) - - if (data.serverStatus) { - Object.entries(data.serverStatus).forEach(([server, status]) => { - serversList[server] = status - }) - } - - return serversList -} - -export const ServicePanels = ({ data, protocol = '' }: DetailProps) => { - const serversList = getServerStatusList(data) - const getProviderFromName = (serviceName: string): string => { - const [, provider] = serviceName.split('@') - return provider || data.provider - } - const providerName = useMemo(() => { - return data.provider - }, [data.provider]) - - return ( - - } title="Service Details"> - - {data.type && ( - - {data.type} - - )} - {data.provider && ( - - - {providerName} - - )} - - {data.status && ( - - - - )} - {data.mirroring && data.mirroring.service && ( - - {data.mirroring.service} - - )} - {data.loadBalancer && ( - <> - {data.loadBalancer.passHostHeader && ( - - - - )} - {data.loadBalancer.terminationDelay && ( - - {`${data.loadBalancer.terminationDelay} ms`} - - )} - - )} - - {data.loadBalancer?.healthCheck && ( - } title="Health Check"> - - - {data.loadBalancer.healthCheck.scheme && ( - - {data.loadBalancer.healthCheck.scheme} - - )} - {data.loadBalancer.healthCheck.interval && ( - - {data.loadBalancer.healthCheck.interval} - - )} - - - {data.loadBalancer.healthCheck.path && ( - - - {data.loadBalancer.healthCheck.path} - - - )} - {data.loadBalancer.healthCheck.timeout && ( - - {data.loadBalancer.healthCheck.timeout} - - )} - - - {data.loadBalancer.healthCheck.port && ( - - {data.loadBalancer.healthCheck.port} - - )} - {data.loadBalancer.healthCheck.hostname && ( - - - {data.loadBalancer.healthCheck.hostname} - - - )} - - {data.loadBalancer.healthCheck.headers && ( - - entry.join(': '))} - /> - - )} - - - )} - {!!data?.weighted?.services?.length && ( - } title="Services" noPadding> - <> - - Name - Weight - Provider - - - {data.weighted.services.map((service) => ( - - {service.name} - {service.weight} - - - - - ))} - - - - )} - {Object.keys(serversList).length > 0 && ( - } title="Servers" noPadding> - <> - - {protocol === 'http' && Status} - URL - - - {Object.entries(serversList).map(([server, status]) => ( - - {protocol === 'http' && } - - - {server} - - - - ))} - - - - )} - {data.mirroring?.mirrors && data.mirroring.mirrors.length > 0 && ( - } title="Mirror Services" noPadding> - - Name - Percent - Provider - - - {data.mirroring.mirrors.map((mirror) => ( - - {mirror.name} - {mirror.percent} - - - ))} - - - )} - - ) -} - -type HttpServiceRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Service right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - -

{data.name}

- - - - ) -} +import { ServiceDetail } from 'components/services/ServiceDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const HttpService = () => { const { name } = useParams<{ name: string }>() - const { data, error } = useResourceDetail(name!, 'services') - return + const { data, error } = useResourceDetail(name ?? '', 'services') + + return } export default HttpService diff --git a/webui/src/pages/http/HttpServices.tsx b/webui/src/pages/http/HttpServices.tsx index e49bc82b6..9864b69a1 100644 --- a/webui/src/pages/http/HttpServices.tsx +++ b/webui/src/pages/http/HttpServices.tsx @@ -1,17 +1,16 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => { const HttpServicesRenderRow = (row) => ( - - - - - + @@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => { {row.loadBalancer?.servers?.length || 0} - - - - - + ) @@ -67,7 +58,7 @@ export const HttpServicesRender = ({ - + diff --git a/webui/src/pages/tcp/TcpMiddleware.spec.tsx b/webui/src/pages/tcp/TcpMiddleware.spec.tsx index 73e69e01c..3e92ec293 100644 --- a/webui/src/pages/tcp/TcpMiddleware.spec.tsx +++ b/webui/src/pages/tcp/TcpMiddleware.spec.tsx @@ -1,12 +1,10 @@ -import { TcpMiddlewareRender } from './TcpMiddleware' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -55,7 +53,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/middlewares/middleware-simple', withPage: true }, ) @@ -66,14 +64,13 @@ describe('', () => { const middlewareCard = getByTestId('middleware-card') expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy() expect(middlewareCard.innerHTML).toContain('Success') - expect(middlewareCard.innerHTML).toContain('inFlightConn') - expect(middlewareCard.innerHTML).toContain('amount') - expect(middlewareCard.innerHTML).toContain('10') + expect(container.innerHTML).toContain('inFlightConn') + expect(container.innerHTML).toContain('amount') + expect(container.innerHTML).toContain('10') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test-simple@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test-simple@docker') }) it('should render a complex middleware', async () => { @@ -106,7 +103,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/middlewares/middleware-complex', withPage: true }, ) @@ -117,17 +114,16 @@ describe('', () => { const middlewareCard = getByTestId('middleware-card') expect(middlewareCard.innerHTML).toContain('Success') expect(middlewareCard.innerHTML).toContain('the-provider') - expect(middlewareCard.innerHTML).toContain('inFlightConn') - expect(middlewareCard.innerHTML).toContain('amount') - expect(middlewareCard.innerHTML).toContain('10') - expect(middlewareCard.innerHTML).toContain('ipWhiteList') - expect(middlewareCard.innerHTML).toContain('source Range') - expect(middlewareCard.innerHTML).toContain('125.0.0.1') - expect(middlewareCard.innerHTML).toContain('125.0.0.4') + expect(container.innerHTML).toContain('inFlightConn') + expect(container.innerHTML).toContain('amount') + expect(container.innerHTML).toContain('10') + expect(container.innerHTML).toContain('ipWhiteList') + expect(container.innerHTML).toContain('source Range') + expect(container.innerHTML).toContain('125.0.0.1') + expect(container.innerHTML).toContain('125.0.0.4') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test-complex@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test-complex@docker') }) }) diff --git a/webui/src/pages/tcp/TcpMiddleware.tsx b/webui/src/pages/tcp/TcpMiddleware.tsx index d85cf22ec..24a41f729 100644 --- a/webui/src/pages/tcp/TcpMiddleware.tsx +++ b/webui/src/pages/tcp/TcpMiddleware.tsx @@ -1,82 +1,12 @@ -import { Card, Box, H1, Skeleton, styled, Text } from '@traefiklabs/faency' -import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' -import { DetailSectionSkeleton } from 'components/resources/DetailSections' -import { RenderMiddleware } from 'components/resources/MiddlewarePanel' -import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' -import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail' -import { NotFound } from 'pages/NotFound' -import breakpoints from 'utils/breakpoints' - -const MiddlewareGrid = styled(Box, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', - - [`@media (max-width: ${breakpoints.tablet})`]: { - gridTemplateColumns: '1fr', - }, -}) - -type TcpMiddlewareRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Middleware right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - -

{data.name}

- - - - - - - - ) -} +import { MiddlewareDetail } from 'components/middlewares/MiddlewareDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const TcpMiddleware = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'middlewares', 'tcp') - return + return } export default TcpMiddleware diff --git a/webui/src/pages/tcp/TcpMiddlewares.tsx b/webui/src/pages/tcp/TcpMiddlewares.tsx index 25bca597b..bb2880fa2 100644 --- a/webui/src/pages/tcp/TcpMiddlewares.tsx +++ b/webui/src/pages/tcp/TcpMiddlewares.tsx @@ -1,17 +1,16 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -24,11 +23,7 @@ export const makeRowRender = (): RenderRowType => { return ( - - - - - + @@ -37,11 +32,7 @@ export const makeRowRender = (): RenderRowType => { - - - - - + ) @@ -69,7 +60,7 @@ export const TcpMiddlewaresRender = ({ - + diff --git a/webui/src/pages/tcp/TcpRouter.spec.tsx b/webui/src/pages/tcp/TcpRouter.spec.tsx index c1f5bb6a4..3423b6626 100644 --- a/webui/src/pages/tcp/TcpRouter.spec.tsx +++ b/webui/src/pages/tcp/TcpRouter.spec.tsx @@ -1,12 +1,10 @@ -import { TcpRouterRender } from './TcpRouter' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { RouterDetail } from 'components/routers/RouterDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -68,38 +66,24 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/routers/tcp-all@docker', withPage: true }, ) const routerStructure = getByTestId('router-structure') expect(routerStructure.innerHTML).toContain(':443') expect(routerStructure.innerHTML).toContain(':8000') - expect(routerStructure.innerHTML).toContain('tcp-all@docker') - expect(routerStructure.innerHTML).toContain('tcp-all') expect(routerStructure.innerHTML).toContain('TCP Router') expect(routerStructure.innerHTML).not.toContain('HTTP Router') const routerDetailsSection = getByTestId('router-details') - const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)') - expect(routerDetailsPanel?.innerHTML).toContain('Status') - expect(routerDetailsPanel?.innerHTML).toContain('Success') - expect(routerDetailsPanel?.innerHTML).toContain('Provider') - expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy() - expect(routerDetailsPanel?.innerHTML).toContain('Name') - expect(routerDetailsPanel?.innerHTML).toContain('tcp-all@docker') - expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints') - expect(routerDetailsPanel?.innerHTML).toContain('web div:nth-child(3)') - const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || []) - expect(middlewaresPanel?.innerHTML).toContain('middleware00') - expect(middlewaresPanel?.innerHTML).toContain('middleware01') - expect(middlewaresPanel?.innerHTML).toContain('Success') - expect(providers.length).toBe(2) + expect(routerDetailsSection?.innerHTML).toContain('Status') + expect(routerDetailsSection?.innerHTML).toContain('Success') + expect(routerDetailsSection?.innerHTML).toContain('Provider') + expect(routerDetailsSection?.querySelector('svg[data-testid="docker"]')).toBeTruthy() + expect(routerStructure.innerHTML).toContain('middleware00') + expect(routerStructure.innerHTML).toContain('middleware01') expect(getByTestId('/tcp/services/tcp-all@docker')).toBeInTheDocument() }) diff --git a/webui/src/pages/tcp/TcpRouter.tsx b/webui/src/pages/tcp/TcpRouter.tsx index 92f9b47b2..b0bfca0cc 100644 --- a/webui/src/pages/tcp/TcpRouter.tsx +++ b/webui/src/pages/tcp/TcpRouter.tsx @@ -1,91 +1,13 @@ -import { Flex, styled, Text } from '@traefiklabs/faency' -import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' -import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections' -import MiddlewarePanel from 'components/resources/MiddlewarePanel' -import RouterPanel from 'components/resources/RouterPanel' -import TlsPanel from 'components/resources/TlsPanel' -import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail' -import { RouterStructure } from 'pages/http/HttpRouter' -import { NotFound } from 'pages/NotFound' - -type DetailProps = { - data: ResourceDetailDataType -} - -const SpacedColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', - gridGap: '16px', -}) - -const RouterDetail = ({ data }: DetailProps) => ( - - - - - -) - -type TcpRouterRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Router right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - - - - - ) -} +import { RouterDetail } from 'components/routers/RouterDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const TcpRouter = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'routers', 'tcp') - return + + return } export default TcpRouter diff --git a/webui/src/pages/tcp/TcpRouters.tsx b/webui/src/pages/tcp/TcpRouters.tsx index 8a8f638ac..12f77dddc 100644 --- a/webui/src/pages/tcp/TcpRouters.tsx +++ b/webui/src/pages/tcp/TcpRouters.tsx @@ -1,18 +1,18 @@ import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' -import { FiShield } from 'react-icons/fi' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' -import { Chips } from 'components/resources/DetailSections' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' +import { Chips } from 'components/resources/DetailItemComponents' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' +import TlsIcon from 'components/routers/TlsIcon' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import Tooltip from 'components/Tooltip' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' @@ -22,17 +22,13 @@ export const makeRowRender = (): RenderRowType => { const TcpRoutersRenderRow = (row) => ( - - - - - + {row.tls && ( - + )} @@ -48,11 +44,7 @@ export const makeRowRender = (): RenderRowType => { - - - - - + @@ -82,7 +74,7 @@ export const TcpRoutersRender = ({ - + diff --git a/webui/src/pages/tcp/TcpService.spec.tsx b/webui/src/pages/tcp/TcpService.spec.tsx index faa8ac0f5..ea001e457 100644 --- a/webui/src/pages/tcp/TcpService.spec.tsx +++ b/webui/src/pages/tcp/TcpService.spec.tsx @@ -1,12 +1,10 @@ -import { TcpServiceRender } from './TcpService' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { ServiceDetail } from 'components/services/ServiceDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -71,7 +69,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/services/mock-service', withPage: true }, ) @@ -79,38 +77,37 @@ describe('', () => { const titleTags = headings.filter((h1) => h1.innerHTML === 'service-test1') expect(titleTags.length).toBe(1) - const serviceDetails = getByTestId('tcp-service-details') + const serviceDetails = getByTestId('service-details') expect(serviceDetails.innerHTML).toContain('Type') expect(serviceDetails.innerHTML).toContain('loadbalancer') expect(serviceDetails.innerHTML).toContain('Provider') expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy() expect(serviceDetails.innerHTML).toContain('Status') expect(serviceDetails.innerHTML).toContain('Success') - expect(serviceDetails.innerHTML).toContain('Termination Delay') + expect(serviceDetails.innerHTML).toContain('Termination delay') expect(serviceDetails.innerHTML).toContain('10 ms') - const healthCheck = getByTestId('tcp-health-check') + const healthCheck = getByTestId('health-check') expect(healthCheck.innerHTML).toContain('Interval') expect(healthCheck.innerHTML).toContain('30s') expect(healthCheck.innerHTML).toContain('Timeout') expect(healthCheck.innerHTML).toContain('10s') expect(healthCheck.innerHTML).toContain('Port') expect(healthCheck.innerHTML).toContain('8080') - expect(healthCheck.innerHTML).toContain('Unhealthy Interval') + expect(healthCheck.innerHTML).toContain('Unhealthy interval') expect(healthCheck.innerHTML).toContain('1m') expect(healthCheck.innerHTML).toContain('Send') expect(healthCheck.innerHTML).toContain('PING') expect(healthCheck.innerHTML).toContain('Expect') expect(healthCheck.innerHTML).toContain('PONG') - const serversList = getByTestId('tcp-servers-list') + const serversList = getByTestId('servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:80') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test1@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test1@docker') }) it('should render the service servers from the serverStatus property', async () => { @@ -153,19 +150,18 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/services/mock-service', withPage: true }, ) - const serversList = getByTestId('tcp-servers-list') + const serversList = getByTestId('servers-list') expect(serversList.childNodes.length).toBe(1) expect(serversList.innerHTML).toContain('http://10.0.1.12:81') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2) - expect(tableBody?.innerHTML).toContain('router-test1@docker') - expect(tableBody?.innerHTML).toContain('router-test2@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2) + expect(routersTable.innerHTML).toContain('router-test1@docker') + expect(routersTable.innerHTML).toContain('router-test2@docker') }) it('should not render used by routers table if the usedBy property is empty', async () => { @@ -180,7 +176,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/tcp/services/mock-service', withPage: true }, ) @@ -223,14 +219,14 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , ) const headings = Array.from(container.getElementsByTagName('h1')) const titleTags = headings.filter((h1) => h1.innerHTML === 'weighted-service-test') expect(titleTags.length).toBe(1) - const serviceDetails = getByTestId('tcp-service-details') + const serviceDetails = getByTestId('service-details') expect(serviceDetails.innerHTML).toContain('Type') expect(serviceDetails.innerHTML).toContain('weighted') expect(serviceDetails.innerHTML).toContain('Provider') @@ -238,7 +234,7 @@ describe('', () => { expect(serviceDetails.innerHTML).toContain('Status') expect(serviceDetails.innerHTML).toContain('Success') - const weightedServices = getByTestId('tcp-weighted-services') + const weightedServices = getByTestId('weighted-services') expect(weightedServices.childNodes.length).toBe(2) expect(weightedServices.innerHTML).toContain('service1@docker') expect(weightedServices.innerHTML).toContain('80') diff --git a/webui/src/pages/tcp/TcpService.tsx b/webui/src/pages/tcp/TcpService.tsx index c36106156..671222f3e 100644 --- a/webui/src/pages/tcp/TcpService.tsx +++ b/webui/src/pages/tcp/TcpService.tsx @@ -1,291 +1,13 @@ -import { Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency' -import { useMemo } from 'react' -import { Helmet } from 'react-helmet-async' -import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi' import { useParams } from 'react-router-dom' -import ProviderIcon from 'components/icons/providers' -import { - DetailSection, - DetailSectionSkeleton, - ItemBlock, - ItemTitle, - LayoutTwoCols, - ProviderName, -} from 'components/resources/DetailSections' -import { ResourceStatus } from 'components/resources/ResourceStatus' -import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' -import Tooltip from 'components/Tooltip' -import { ResourceDetailDataType, ServiceDetailType, useResourceDetail } from 'hooks/use-resource-detail' -import { NotFound } from 'pages/NotFound' - -type TcpDetailProps = { - data: ServiceDetailType -} - -const SpacedColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', - gridGap: '16px', -}) - -const ServicesGrid = styled(Box, { - display: 'grid', - gridTemplateColumns: '2fr 1fr 1fr', - alignItems: 'center', - padding: '$3 $5', - borderBottom: '1px solid $tableRowBorder', -}) - -const ServersGrid = styled(Box, { - display: 'grid', - alignItems: 'center', - padding: '$3 $5', - borderBottom: '1px solid $tableRowBorder', -}) - -const GridTitle = styled(Text, { - fontSize: '14px', - fontWeight: 700, - color: 'hsl(0, 0%, 56%)', -}) - -type TcpServer = { - address: string -} - -type ServerStatus = { - [server: string]: string -} - -type TcpHealthCheck = { - port?: number - send?: string - expect?: string - interval?: string - unhealthyInterval?: string - timeout?: string -} - -function getTcpServerStatusList(data: ServiceDetailType): ServerStatus { - const serversList: ServerStatus = {} - - data.loadBalancer?.servers?.forEach((server: any) => { - // TCP servers should have address, but handle both url and address for compatibility - const serverKey = (server as TcpServer).address || (server as any).url - if (serverKey) { - serversList[serverKey] = 'DOWN' - } - }) - - if (data.serverStatus) { - Object.entries(data.serverStatus).forEach(([server, status]) => { - serversList[server] = status - }) - } - - return serversList -} - -export const TcpServicePanels = ({ data }: TcpDetailProps) => { - const serversList = getTcpServerStatusList(data) - const getProviderFromName = (serviceName: string): string => { - const [, provider] = serviceName.split('@') - return provider || data.provider - } - const providerName = useMemo(() => { - return data.provider - }, [data.provider]) - - return ( - - } title="Service Details"> - - {data.type && ( - - {data.type} - - )} - {data.provider && ( - - - {providerName} - - )} - - {data.status && ( - - - - )} - {data.loadBalancer && ( - <> - {data.loadBalancer.terminationDelay && ( - - {`${data.loadBalancer.terminationDelay} ms`} - - )} - - )} - - {data.loadBalancer?.healthCheck && ( - } title="Health Check"> - - {(() => { - const tcpHealthCheck = data.loadBalancer.healthCheck as unknown as TcpHealthCheck - return ( - <> - - {tcpHealthCheck.interval && ( - - {tcpHealthCheck.interval} - - )} - {tcpHealthCheck.timeout && ( - - {tcpHealthCheck.timeout} - - )} - - - {tcpHealthCheck.port && ( - - {tcpHealthCheck.port} - - )} - {tcpHealthCheck.unhealthyInterval && ( - - {tcpHealthCheck.unhealthyInterval} - - )} - - - {tcpHealthCheck.send && ( - - - {tcpHealthCheck.send} - - - )} - {tcpHealthCheck.expect && ( - - - {tcpHealthCheck.expect} - - - )} - - - ) - })()} - - - )} - {!!data?.weighted?.services?.length && ( - } title="Services" noPadding> - <> - - Name - Weight - Provider - - - {data.weighted.services.map((service) => ( - - {service.name} - {service.weight} - - - - - ))} - - - - )} - {Object.keys(serversList).length > 0 && ( - } title="Servers" noPadding> - <> - - Status - Address - - - {Object.entries(serversList).map(([server, status]) => ( - - - - - {server} - - - - ))} - - - - )} - - ) -} - -type TcpServiceRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Service right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - -

{data.name}

- - - - ) -} +import { ServiceDetail } from 'components/services/ServiceDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const TcpService = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'services', 'tcp') - return + + return } export default TcpService diff --git a/webui/src/pages/tcp/TcpServices.tsx b/webui/src/pages/tcp/TcpServices.tsx index 13df8792b..f02a3160b 100644 --- a/webui/src/pages/tcp/TcpServices.tsx +++ b/webui/src/pages/tcp/TcpServices.tsx @@ -1,17 +1,16 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => { const TcpServicesRenderRow = (row) => ( - - - - - + @@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => { {row.loadBalancer?.servers?.length || 0} - - - - - + ) @@ -67,7 +58,7 @@ export const TcpServicesRender = ({ - + diff --git a/webui/src/pages/udp/UdpRouter.spec.tsx b/webui/src/pages/udp/UdpRouter.spec.tsx index 2404b70a3..23f80d663 100644 --- a/webui/src/pages/udp/UdpRouter.spec.tsx +++ b/webui/src/pages/udp/UdpRouter.spec.tsx @@ -1,12 +1,10 @@ -import { UdpRouterRender } from './UdpRouter' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { RouterDetail } from 'components/routers/RouterDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -53,31 +51,22 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/udp/routers/udp-all@docker', withPage: true }, ) const routerStructure = getByTestId('router-structure') expect(routerStructure.innerHTML).toContain(':443') expect(routerStructure.innerHTML).toContain(':8000') - expect(routerStructure.innerHTML).toContain('udp-all@docker') - expect(routerStructure.innerHTML).toContain('udp-all') expect(routerStructure.innerHTML).toContain('UDP Router') expect(routerStructure.innerHTML).not.toContain('HTTP Router') const routerDetailsSection = getByTestId('router-details') - const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)') - expect(routerDetailsPanel?.innerHTML).toContain('Status') - expect(routerDetailsPanel?.innerHTML).toContain('Success') - expect(routerDetailsPanel?.innerHTML).toContain('Provider') - expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy() - expect(routerDetailsPanel?.innerHTML).toContain('Name') - expect(routerDetailsPanel?.innerHTML).toContain('udp-all@docker') - expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints') - expect(routerDetailsPanel?.innerHTML).toContain('web ( - - - -) - -type UdpRouterRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Router right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - - - - - ) -} +import { RouterDetail } from 'components/routers/RouterDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const UdpRouter = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'routers', 'udp') - return + return } export default UdpRouter diff --git a/webui/src/pages/udp/UdpRouters.tsx b/webui/src/pages/udp/UdpRouters.tsx index b468630ce..880b9c5d3 100644 --- a/webui/src/pages/udp/UdpRouters.tsx +++ b/webui/src/pages/udp/UdpRouters.tsx @@ -1,18 +1,17 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' -import { Chips } from 'components/resources/DetailSections' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' +import { Chips } from 'components/resources/DetailItemComponents' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -21,11 +20,7 @@ export const makeRowRender = (): RenderRowType => { const UdpRoutersRenderRow = (row) => ( - - - - - + {row.entryPoints && row.entryPoints.length > 0 && } @@ -35,11 +30,7 @@ export const makeRowRender = (): RenderRowType => { - - - - - + @@ -69,7 +60,7 @@ export const UdpRoutersRender = ({ - + diff --git a/webui/src/pages/udp/UdpService.spec.tsx b/webui/src/pages/udp/UdpService.spec.tsx index b6150c8ee..6f3dd1c47 100644 --- a/webui/src/pages/udp/UdpService.spec.tsx +++ b/webui/src/pages/udp/UdpService.spec.tsx @@ -1,12 +1,10 @@ -import { UdpServiceRender } from './UdpService' - -import { ResourceDetailDataType } from 'hooks/use-resource-detail' +import { ServiceDetail } from 'components/services/ServiceDetail' import { renderWithProviders } from 'utils/test' describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() @@ -14,7 +12,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() @@ -22,7 +20,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( - , + , { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() @@ -61,7 +59,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/udp/services/mock-service', withPage: true }, ) @@ -76,9 +74,9 @@ describe('', () => { expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy() expect(serviceDetails.innerHTML).toContain('Status') expect(serviceDetails.innerHTML).toContain('Success') - expect(serviceDetails.innerHTML).toContain('Pass Host Header') + expect(serviceDetails.innerHTML).toContain('Pass host header') expect(serviceDetails.innerHTML).toContain('True') - expect(serviceDetails.innerHTML).toContain('Termination Delay') + expect(serviceDetails.innerHTML).toContain('Termination delay') expect(serviceDetails.innerHTML).toContain('10 ms') const serversList = getByTestId('servers-list') @@ -86,9 +84,8 @@ describe('', () => { expect(serversList.innerHTML).toContain('http://10.0.1.12:80') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1) - expect(tableBody?.innerHTML).toContain('router-test1@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(1) + expect(routersTable.innerHTML).toContain('router-test1@docker') }) it('should render the service servers from the serverStatus property', async () => { @@ -131,7 +128,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/udp/services/mock-service', withPage: true }, ) @@ -140,10 +137,9 @@ describe('', () => { expect(serversList.innerHTML).toContain('http://10.0.1.12:81') const routersTable = getByTestId('routers-table') - const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1] - expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2) - expect(tableBody?.innerHTML).toContain('router-test1@docker') - expect(tableBody?.innerHTML).toContain('router-test2@docker') + expect(routersTable.querySelectorAll('a[role="row"]')).toHaveLength(2) + expect(routersTable.innerHTML).toContain('router-test1@docker') + expect(routersTable.innerHTML).toContain('router-test2@docker') }) it('should not render used by routers table if the usedBy property is empty', async () => { @@ -158,7 +154,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any - , + , { route: '/udp/services/mock-service', withPage: true }, ) diff --git a/webui/src/pages/udp/UdpService.tsx b/webui/src/pages/udp/UdpService.tsx index 132e63197..6ba25fd2f 100644 --- a/webui/src/pages/udp/UdpService.tsx +++ b/webui/src/pages/udp/UdpService.tsx @@ -1,75 +1,13 @@ -import { Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency' -import { Helmet } from 'react-helmet-async' import { useParams } from 'react-router-dom' -import { DetailSectionSkeleton } from 'components/resources/DetailSections' -import { UsedByRoutersSection, UsedByRoutersSkeleton } from 'components/resources/UsedByRoutersSection' -import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail' -import { ServicePanels } from 'pages/http/HttpService' -import { NotFound } from 'pages/NotFound' - -const SpacedColumns = styled(Flex, { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))', - gridGap: '16px', -}) - -type UdpServiceRenderProps = { - data?: ResourceDetailDataType - error?: Error - name: string -} - -export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) => { - if (error) { - return ( - <> - - {name} - Traefik Proxy - - - Sorry, we could not fetch detail information for this Service right now. Please, try again later. - - - ) - } - - if (!data) { - return ( - <> - - {name} - Traefik Proxy - - - - - - - - - ) - } - - if (!data.name) { - return - } - - return ( - <> - - {data.name} - Traefik Proxy - -

{data.name}

- - - - ) -} +import { ServiceDetail } from 'components/services/ServiceDetail' +import { useResourceDetail } from 'hooks/use-resource-detail' export const UdpService = () => { const { name } = useParams<{ name: string }>() const { data, error } = useResourceDetail(name!, 'services', 'udp') - return + + return } export default UdpService diff --git a/webui/src/pages/udp/UdpServices.tsx b/webui/src/pages/udp/UdpServices.tsx index 76abc3d02..c87be266a 100644 --- a/webui/src/pages/udp/UdpServices.tsx +++ b/webui/src/pages/udp/UdpServices.tsx @@ -1,17 +1,16 @@ -import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency' +import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Flex, Text } from '@traefiklabs/faency' import { useMemo } from 'react' import { Helmet } from 'react-helmet-async' import useInfiniteScroll from 'react-infinite-scroll-hook' import { useSearchParams } from 'react-router-dom' -import ClickableRow from 'components/ClickableRow' -import ProviderIcon from 'components/icons/providers' +import { ScrollTopButton } from 'components/buttons/ScrollTopButton' +import { ProviderIconWithTooltip } from 'components/icons/providers' import { ResourceStatus } from 'components/resources/ResourceStatus' -import { ScrollTopButton } from 'components/ScrollTopButton' import { SpinnerLoader } from 'components/SpinnerLoader' -import { searchParamsToState, TableFilter } from 'components/TableFilter' +import ClickableRow from 'components/tables/ClickableRow' import SortableTh from 'components/tables/SortableTh' -import Tooltip from 'components/Tooltip' +import { searchParamsToState, TableFilter } from 'components/tables/TableFilter' import TooltipText from 'components/TooltipText' import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination' import { EmptyPlaceholderTd } from 'layout/EmptyPlaceholder' @@ -20,11 +19,7 @@ export const makeRowRender = (): RenderRowType => { const UdpServicesRenderRow = (row) => ( - - - - - + @@ -36,11 +31,7 @@ export const makeRowRender = (): RenderRowType => { {row.loadBalancer?.servers?.length || 0} - - - - - + ) @@ -67,7 +58,7 @@ export const UdpServicesRender = ({ - + diff --git a/webui/src/types/object.d.ts b/webui/src/types/object.d.ts new file mode 100644 index 000000000..9321ca555 --- /dev/null +++ b/webui/src/types/object.d.ts @@ -0,0 +1,9 @@ +declare namespace Object { + type JSONObject = { + [x: string]: string | number + } + + type ValuesMapType = { + [key: string]: string | number | JSONObject + } +} diff --git a/webui/src/types/resources.d.ts b/webui/src/types/resources.d.ts new file mode 100644 index 000000000..7bed033e0 --- /dev/null +++ b/webui/src/types/resources.d.ts @@ -0,0 +1,123 @@ +declare namespace Resource { + type Status = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled' | 'loading' + + type DetailsData = Router.DetailsData & Service.Details & Middleware.DetailsData +} + +declare namespace Entrypoint { + type Details = { + name: string + address: string + message?: string + } +} + +declare namespace Router { + type TlsDomain = { + main: string + sans: string[] + } + + type TLS = { + options: string + certResolver: string + domains: TlsDomain[] + passthrough: boolean + } + + type Details = { + name: string + service?: string + status: 'enabled' | 'disabled' | 'warning' + rule?: string + priority?: number + provider: string + tls?: { + options: string + certResolver: string + domains: TlsDomain[] + passthrough: boolean + } + error?: string[] + entryPoints?: string[] + message?: string + } + + type DetailsData = Details & { + middlewares?: Middleware.Details[] + hasValidMiddlewares?: boolean + entryPointsData?: Entrypoint.Details[] + using?: string[] + } +} + +declare namespace Service { + type WeightedService = { + name: string + weight: number + } + + type Mirror = { + name: string + percent: number + } + + type Details = { + name: string + status: 'enabled' | 'disabled' | 'warning' + provider: string + type: string + usedBy?: string[] + routers?: Router[] + serverStatus?: { + [server: string]: string + } + mirroring?: { + service: string + mirrors?: Mirror[] + } + loadBalancer?: { + servers?: { url: string }[] + passHostHeader?: boolean + terminationDelay?: number + healthCheck?: { + scheme: string + path: string + hostname: string + headers?: { + [header: string]: string + } + port?: number + send?: string + expect?: string + interval?: string + unhealthyInterval?: string + timeout?: string + } + } + weighted?: { + services?: WeightedService[] + } + } +} + +declare namespace Middleware { + type Props = { + [prop: string]: ValuesMapType + } + + type Details = { + name: string + status: 'enabled' | 'disabled' | 'warning' + provider: string + type?: string + plugin?: Record + error?: string[] + routers?: string[] + usedBy?: string[] + } & Props + + type DetailsData = Details & { + routers?: Router.Details[] + } +}