1
0
Fork 0

Add Traefik Hub demo in dashboard

This commit is contained in:
Gina A. 2025-10-27 17:40:06 +01:00 committed by GitHub
parent 10be359327
commit db4f262916
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
64 changed files with 2481 additions and 622 deletions

View file

@ -43,6 +43,8 @@
"type": "module",
"dependencies": {
"@eslint/js": "^9.32.0",
"@noble/ed25519": "^3.0.0",
"@noble/hashes": "^2.0.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1",
@ -56,6 +58,7 @@
"@typescript-eslint/parser": "^8.38.0",
"@vitejs/plugin-react": "^4.7.0",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/web-worker": "^4.0.2",
"chart.js": "^4.4.1",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
@ -85,7 +88,7 @@
"usehooks-ts": "^2.14.0",
"vite": "^5.4.19",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4",
"vitest": "^4.0.3",
"vitest-canvas-mock": "^0.3.3"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

View file

@ -1,5 +1,5 @@
import { globalCss, Box, darkTheme, FaencyProvider, lightTheme } from '@traefiklabs/faency'
import { Suspense, useEffect } from 'react'
import { Suspense, useContext, useEffect } from 'react'
import { HelmetProvider } from 'react-helmet-async'
import { HashRouter, Navigate, Route, Routes as RouterRoutes, useLocation } from 'react-router-dom'
import { SWRConfig } from 'swr'
@ -12,6 +12,7 @@ import { useIsDarkMode } from 'hooks/use-theme'
import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper'
import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages } from 'pages'
import { DashboardSkeleton } from 'pages/dashboard/Dashboard'
import { HubDemoContext, HubDemoProvider } from 'pages/hub-demo/demoNavContext'
export const LIGHT_THEME = lightTheme('blue')
export const DARK_THEME = darkTheme('blue')
@ -33,47 +34,56 @@ const ScrollToTop = () => {
}
export const Routes = () => {
const { routes: hubDemoRoutes } = useContext(HubDemoContext)
return (
<Suspense fallback={<PageLoader />}>
<RouterRoutes>
<Route
path="/"
element={
<ErrorSuspenseWrapper suspenseFallback={<DashboardSkeleton />}>
<Dashboard />
</ErrorSuspenseWrapper>
}
/>
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
<Route path="/tcp/routers" element={<TCPPages.TcpRouters />} />
<Route path="/tcp/services" element={<TCPPages.TcpServices />} />
<Route path="/tcp/middlewares" element={<TCPPages.TcpMiddlewares />} />
<Route path="/udp/routers" element={<UDPPages.UdpRouters />} />
<Route path="/udp/services" element={<UDPPages.UdpServices />} />
<Route path="/http/routers/:name" element={<HTTPPages.HttpRouter />} />
<Route path="/http/services/:name" element={<HTTPPages.HttpService />} />
<Route path="/http/middlewares/:name" element={<HTTPPages.HttpMiddleware />} />
<Route path="/tcp/routers/:name" element={<TCPPages.TcpRouter />} />
<Route path="/tcp/services/:name" element={<TCPPages.TcpService />} />
<Route path="/tcp/middlewares/:name" element={<TCPPages.TcpMiddleware />} />
<Route path="/udp/routers/:name" element={<UDPPages.UdpRouter />} />
<Route path="/udp/services/:name" element={<UDPPages.UdpService />} />
<Route path="/http" element={<Navigate to="/http/routers" replace />} />
<Route path="/tcp" element={<Navigate to="/tcp/routers" replace />} />
<Route path="/udp" element={<Navigate to="/udp/routers" replace />} />
<Route path="*" element={<NotFound />} />
</RouterRoutes>
</Suspense>
<Page>
<Suspense fallback={<PageLoader />}>
<RouterRoutes>
<Route
path="/"
element={
<ErrorSuspenseWrapper suspenseFallback={<DashboardSkeleton />}>
<Dashboard />
</ErrorSuspenseWrapper>
}
/>
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
<Route path="/tcp/routers" element={<TCPPages.TcpRouters />} />
<Route path="/tcp/services" element={<TCPPages.TcpServices />} />
<Route path="/tcp/middlewares" element={<TCPPages.TcpMiddlewares />} />
<Route path="/udp/routers" element={<UDPPages.UdpRouters />} />
<Route path="/udp/services" element={<UDPPages.UdpServices />} />
<Route path="/http/routers/:name" element={<HTTPPages.HttpRouter />} />
<Route path="/http/services/:name" element={<HTTPPages.HttpService />} />
<Route path="/http/middlewares/:name" element={<HTTPPages.HttpMiddleware />} />
<Route path="/tcp/routers/:name" element={<TCPPages.TcpRouter />} />
<Route path="/tcp/services/:name" element={<TCPPages.TcpService />} />
<Route path="/tcp/middlewares/:name" element={<TCPPages.TcpMiddleware />} />
<Route path="/udp/routers/:name" element={<UDPPages.UdpRouter />} />
<Route path="/udp/services/:name" element={<UDPPages.UdpService />} />
<Route path="/http" element={<Navigate to="/http/routers" replace />} />
<Route path="/tcp" element={<Navigate to="/tcp/routers" replace />} />
<Route path="/udp" element={<Navigate to="/udp/routers" replace />} />
{/* Hub Dashboard demo content */}
{hubDemoRoutes?.map((route, idx) => <Route key={`hub-${idx}`} path={route.path} element={route.element} />)}
<Route path="*" element={<NotFound />} />
</RouterRoutes>
</Suspense>
</Page>
)
}
const isDev = import.meta.env.NODE_ENV === 'development'
const customGlobalStyle = globalCss({
'span[role=cell]': { // target the AriaTd component
p: '$2 $3'
// target the AriaTd component, but exclude anything inside hub-ui-demo-app
'body:not(:has(hub-ui-demo-app)) span[role=cell]': {
p: '$2 $3',
},
})
@ -101,9 +111,11 @@ const App = () => {
>
<VersionProvider>
<HashRouter basename={import.meta.env.VITE_APP_BASE_URL || ''}>
{customGlobalStyle()}
<ScrollToTop />
<Routes />
<HubDemoProvider basePath={'/hub-dashboard'}>
{customGlobalStyle()}
<ScrollToTop />
<Routes />
</HubDemoProvider>
</HashRouter>
</VersionProvider>
</SWRConfig>

View file

@ -2,17 +2,17 @@ import { Flex } from '@traefiklabs/faency'
import { motion } from 'framer-motion'
import { FiLoader } from 'react-icons/fi'
export const SpinnerLoader = () => (
export const SpinnerLoader = ({ size = 24 }: { size?: number }) => (
<motion.div
animate={{
rotate: 360,
}}
transition={{ ease: 'linear', duration: 1, repeat: Infinity }}
style={{ width: 24, height: 24 }}
style={{ width: size, height: size }}
data-testid="loading"
>
<Flex css={{ color: '$primary' }}>
<FiLoader size={24} />
<FiLoader size={size} />
</Flex>
</motion.div>
)

View file

@ -2,10 +2,12 @@ import { ProviderIconProps } from 'components/icons/providers'
export default function Knative(props: ProviderIconProps) {
return (
<svg height="72" width="72" viewBox="-14 0 92 72" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="m30.42 7.074 14.142000000000001 6.8100000000000005 -2.745 4.752000000000001a0.804 0.804 0 0 0 -0.096 0.546l1.821 10.323a0.789 0.789 0 0 0 0.279 0.48l8.028 6.735c0.14400000000000002 0.123 0.33 0.192 0.522 0.192h5.6339999999999995l1.521 6.66a1.476 1.476 0 0 1 -0.28500000000000003 1.2449999999999999l-15.711 19.701a1.4729999999999999 1.4729999999999999 0 0 1 -1.149 0.552h-25.200000000000003a1.4729999999999999 1.4729999999999999 0 0 1 -1.149 -0.552L0.321 44.817a1.476 1.476 0 0 1 -0.28500000000000003 -1.2449999999999999l5.607 -24.567a1.482 1.482 0 0 1 0.798 -0.9990000000000001l22.701 -10.932a1.47 1.47 0 0 1 1.278 0ZM21.732 49.878h5.001v-7.286999999999999l1.92 -2.3520000000000003 5.466 9.639h5.8950000000000005l-7.782 -12.818999999999999 7.386000000000001 -9.507h-6.195l-5.067 7.419c-0.498 0.795 -1.026 1.59 -1.524 2.4509999999999996h-0.099v-9.870000000000001H21.732v22.326ZM57.842999999999996 7.055999999999999l8.925 3.2489999999999997c0.162 0.06 0.29700000000000004 0.17400000000000002 0.384 0.324l4.749 8.225999999999999c0.08700000000000001 0.15000000000000002 0.11699999999999999 0.324 0.08700000000000001 0.495l-1.6500000000000001 9.354a0.729 0.729 0 0 1 -0.249 0.43499999999999994l-7.2780000000000005 6.105a0.735 0.735 0 0 1 -0.471 0.17400000000000002h-9.498a0.738 0.738 0 0 1 -0.474 -0.17400000000000002l-7.2749999999999995 -6.105a0.72 0.72 0 0 1 -0.252 -0.43499999999999994l-1.6500000000000001 -9.354a0.732 0.732 0 0 1 0.08700000000000001 -0.495l4.749 -8.225999999999999a0.735 0.735 0 0 1 0.387 -0.324l8.925 -3.2489999999999997a0.729 0.729 0 0 1 0.504 0Zm-2.13 10.212c-0.096 -0.276 -0.29400000000000004 -0.41100000000000003 -0.591 -0.41100000000000003h-1.4609999999999999V25.71h2.37V19.347c0.264 -0.258 0.54 -0.45899999999999996 0.8340000000000001 -0.6000000000000001a2.082 2.082 0 0 1 0.9359999999999999 -0.21599999999999997c0.44699999999999995 0 0.783 0.135 1.014 0.40800000000000003 0.22799999999999998 0.273 0.342 0.654 0.342 1.146V25.71h2.361V20.085c0 -0.492 -0.063 -0.9450000000000001 -0.192 -1.356a2.964 2.964 0 0 0 -0.5760000000000001 -1.065 2.625 2.625 0 0 0 -0.9390000000000001 -0.6960000000000001 3.6239999999999997 3.6239999999999997 0 0 0 -2.0909999999999997 -0.162 3.5279999999999996 3.5279999999999996 0 0 0 -1.308 0.609 5.868 5.868 0 0 0 -0.552 0.471l-0.14700000000000002 -0.618Z"
fill="currentColor"
stroke-width="3"/>
<svg height="72" width="72" viewBox="-14 0 92 72" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="m30.42 7.074 14.142000000000001 6.8100000000000005 -2.745 4.752000000000001a0.804 0.804 0 0 0 -0.096 0.546l1.821 10.323a0.789 0.789 0 0 0 0.279 0.48l8.028 6.735c0.14400000000000002 0.123 0.33 0.192 0.522 0.192h5.6339999999999995l1.521 6.66a1.476 1.476 0 0 1 -0.28500000000000003 1.2449999999999999l-15.711 19.701a1.4729999999999999 1.4729999999999999 0 0 1 -1.149 0.552h-25.200000000000003a1.4729999999999999 1.4729999999999999 0 0 1 -1.149 -0.552L0.321 44.817a1.476 1.476 0 0 1 -0.28500000000000003 -1.2449999999999999l5.607 -24.567a1.482 1.482 0 0 1 0.798 -0.9990000000000001l22.701 -10.932a1.47 1.47 0 0 1 1.278 0ZM21.732 49.878h5.001v-7.286999999999999l1.92 -2.3520000000000003 5.466 9.639h5.8950000000000005l-7.782 -12.818999999999999 7.386000000000001 -9.507h-6.195l-5.067 7.419c-0.498 0.795 -1.026 1.59 -1.524 2.4509999999999996h-0.099v-9.870000000000001H21.732v22.326ZM57.842999999999996 7.055999999999999l8.925 3.2489999999999997c0.162 0.06 0.29700000000000004 0.17400000000000002 0.384 0.324l4.749 8.225999999999999c0.08700000000000001 0.15000000000000002 0.11699999999999999 0.324 0.08700000000000001 0.495l-1.6500000000000001 9.354a0.729 0.729 0 0 1 -0.249 0.43499999999999994l-7.2780000000000005 6.105a0.735 0.735 0 0 1 -0.471 0.17400000000000002h-9.498a0.738 0.738 0 0 1 -0.474 -0.17400000000000002l-7.2749999999999995 -6.105a0.72 0.72 0 0 1 -0.252 -0.43499999999999994l-1.6500000000000001 -9.354a0.732 0.732 0 0 1 0.08700000000000001 -0.495l4.749 -8.225999999999999a0.735 0.735 0 0 1 0.387 -0.324l8.925 -3.2489999999999997a0.729 0.729 0 0 1 0.504 0Zm-2.13 10.212c-0.096 -0.276 -0.29400000000000004 -0.41100000000000003 -0.591 -0.41100000000000003h-1.4609999999999999V25.71h2.37V19.347c0.264 -0.258 0.54 -0.45899999999999996 0.8340000000000001 -0.6000000000000001a2.082 2.082 0 0 1 0.9359999999999999 -0.21599999999999997c0.44699999999999995 0 0.783 0.135 1.014 0.40800000000000003 0.22799999999999998 0.273 0.342 0.654 0.342 1.146V25.71h2.361V20.085c0 -0.492 -0.063 -0.9450000000000001 -0.192 -1.356a2.964 2.964 0 0 0 -0.5760000000000001 -1.065 2.625 2.625 0 0 0 -0.9390000000000001 -0.6960000000000001 3.6239999999999997 3.6239999999999997 0 0 0 -2.0909999999999997 -0.162 3.5279999999999996 3.5279999999999996 0 0 0 -1.308 0.609 5.868 5.868 0 0 0 -0.552 0.471l-0.14700000000000002 -0.618Z"
fill="currentColor"
stroke-width="3"
/>
</svg>
)
}

View file

@ -8,7 +8,7 @@ import File from 'components/icons/providers/File'
import Http from 'components/icons/providers/Http'
import Hub from 'components/icons/providers/Hub'
import Internal from 'components/icons/providers/Internal'
import Knative from "components/icons/providers/Knative";
import Knative from 'components/icons/providers/Knative'
import Kubernetes from 'components/icons/providers/Kubernetes'
import Nomad from 'components/icons/providers/Nomad'
import Plugin from 'components/icons/providers/Plugin'

View file

@ -2,6 +2,7 @@ import {
Badge,
Box,
Button,
CSS,
DialogTitle,
DropdownMenu,
DropdownMenuContent,
@ -37,6 +38,7 @@ import TooltipText from 'components/TooltipText'
import { VersionContext } from 'contexts/version'
import useTotals from 'hooks/use-overview-totals'
import { useIsDarkMode } from 'hooks/use-theme'
import ApimDemoNavMenu from 'pages/hub-demo/HubDemoNav'
import { Route, ROUTES } from 'routes'
export const LAPTOP_BP = 1025
@ -54,7 +56,7 @@ const NavigationDrawer = styled(Flex, {
},
})
const BasicNavigationItem = ({
export const BasicNavigationItem = ({
route,
count,
isSmallScreen,
@ -270,7 +272,7 @@ export const SideNav = ({
))}
</Flex>
))}
<Flex direction="column" css={{ borderTop: '1px solid $colors$tableRowBorder', borderRadius: 0, pt: '$3' }}>
<Flex direction="column" css={{ borderTop: '1px solid $colors$tableRowBorder', borderRadius: 0, py: '$3' }}>
<NavigationLink
startAdornment={<PluginsIcon />}
css={{
@ -283,12 +285,14 @@ export const SideNav = ({
{!isSmallScreen || isExpanded ? 'Plugins' : ''}
</NavigationLink>
</Flex>
<ApimDemoNavMenu isResponsive={isResponsive} isSmallScreen={isSmallScreen} isExpanded={isExpanded} />
</Container>
</NavigationDrawer>
)
}
export const TopNav = () => {
export const TopNav = ({ css, noHubButton = false }: { css?: CSS; noHubButton?: boolean }) => {
const [hasHubButtonComponent, setHasHubButtonComponent] = useState(false)
const { showHubButton, version } = useContext(VersionContext)
const isDarkMode = useIsDarkMode()
@ -341,8 +345,8 @@ export const TopNav = () => {
}, [showHubButton])
return (
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6' }}>
{hasHubButtonComponent && (
<Flex as="nav" role="navigation" justify="end" align="center" css={{ gap: '$2', mb: '$6', ...css }}>
{!noHubButton && hasHubButtonComponent && (
<Box css={{ fontFamily: '$rubik', fontWeight: '500 !important' }}>
<hub-button-app
key={`dark-mode-${isDarkMode}`}

View file

@ -4,7 +4,7 @@ import { renderWithProviders } from 'utils/test'
describe('<Page />', () => {
it('should render an empty page', () => {
const { getByTestId } = renderWithProviders(<Page title="Test" />)
expect(getByTestId('Test page')).toBeInTheDocument()
const { getByTestId } = renderWithProviders(<Page />, { route: '/test' })
expect(getByTestId('/test page')).toBeInTheDocument()
})
})

View file

@ -1,6 +1,7 @@
import { Flex, globalCss, styled } from '@traefiklabs/faency'
import { ReactNode, useState } from 'react'
import { ReactNode, useMemo, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useLocation } from 'react-router-dom'
import Container from './Container'
import { LAPTOP_BP, SideBarPanel, SideNav, TopNav } from './Navigation'
@ -40,14 +41,31 @@ export interface Props {
children?: ReactNode
}
const Page = ({ children, title }: Props) => {
const Page = ({ children }: Props) => {
const { pathname } = useLocation()
const [isSideBarPanelOpen, setIsSideBarPanelOpen] = useState(false)
const location = useLocation()
const isDemoPage = useMemo(() => pathname.includes('hub-dashboard'), [pathname])
const renderedContent = useMemo(() => {
if (isDemoPage) {
return children
}
return (
<PageContainer data-testid={`${location.pathname} page`} direction="column">
<TopNav />
{children}
</PageContainer>
)
}, [children, isDemoPage, location.pathname])
return (
<ToastProvider>
{globalStyles()}
<Helmet>
<title>{title ? `${title} - ` : ''}Traefik Proxy</title>
<title>Traefik Proxy</title>
</Helmet>
<Flex>
<SideBarPanel isOpen={isSideBarPanelOpen} onOpenChange={setIsSideBarPanelOpen} />
@ -56,10 +74,7 @@ const Page = ({ children, title }: Props) => {
justify="center"
css={{ flex: 1, margin: 'auto', ml: 264, [`@media (max-width:${LAPTOP_BP}px)`]: { ml: 60 } }}
>
<PageContainer data-testid={`${title} page`} direction="column">
<TopNav />
{children}
</PageContainer>
{renderedContent}
</Flex>
</Flex>
<ToastPool />

View file

@ -1,24 +1,24 @@
import { Box, Button, Flex, H1, Text } from '@traefiklabs/faency'
import { Helmet } from 'react-helmet-async'
import { useNavigate } from 'react-router-dom'
import Page from 'layout/Page'
export const NotFound = () => {
const navigate = useNavigate()
return (
<Page title="Not found">
<Flex css={{ flexDirection: 'column', alignItems: 'center', p: '$6' }}>
<Box>
<H1 style={{ fontSize: '80px', lineHeight: '120px' }}>404</H1>
</Box>
<Box css={{ pb: '$3' }}>
<Text size={6}>I&apos;m sorry, nothing around here...</Text>
</Box>
<Button variant="primary" onClick={() => navigate(-1)}>
Go back
</Button>
</Flex>
</Page>
<Flex css={{ flexDirection: 'column', alignItems: 'center', p: '$6' }} data-testid="Not found page">
<Helmet>
<title>Not found - Traefik Proxy</title>
</Helmet>
<Box>
<H1 style={{ fontSize: '80px', lineHeight: '120px' }}>404</H1>
</Box>
<Box css={{ pb: '$3' }}>
<Text size={6}>I&apos;m sorry, nothing around here...</Text>
</Box>
<Button variant="primary" onClick={() => navigate(-1)}>
Go back
</Button>
</Flex>
)
}

View file

@ -1,12 +1,12 @@
import { Card, CSS, Flex, Grid, H2, Text } from '@traefiklabs/faency'
import { ReactNode, useMemo } from 'react'
import { Helmet } from 'react-helmet-async'
import useSWR from 'swr'
import ProviderIcon from 'components/icons/providers'
import FeatureCard, { FeatureCardSkeleton } from 'components/resources/FeatureCard'
import ResourceCard from 'components/resources/ResourceCard'
import TraefikResourceStatsCard, { StatsCardSkeleton } from 'components/resources/TraefikResourceStatsCard'
import Page from 'layout/Page'
import { capitalizeFirstLetter } from 'utils/string'
const RESOURCES = ['routers', 'services', 'middlewares']
@ -76,159 +76,158 @@ export const Dashboard = () => {
}
return (
<Page title="Dashboard">
<Flex direction="column" gap={6}>
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
{entrypoints?.map((i, idx) => (
<ResourceCard
key={`entrypoint-${i.name}-${idx}`}
css={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: '125px',
}}
title={i.name}
titleCSS={{ textAlign: 'center' }}
>
<Text css={{ fontSize: '$11', fontWeight: 500, wordBreak: 'break-word' }}>{i.address}</Text>
</ResourceCard>
))}
</SectionContainer>
<Flex direction="column" gap={6}>
<Helmet>
<title>Dashboard - Traefik Proxy</title>
</Helmet>
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
{entrypoints?.map((i, idx) => (
<ResourceCard
key={`entrypoint-${i.name}-${idx}`}
css={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
minHeight: '125px',
}}
title={i.name}
titleCSS={{ textAlign: 'center' }}
>
<Text css={{ fontSize: '$11', fontWeight: 500, wordBreak: 'break-word' }}>{i.address}</Text>
</ResourceCard>
))}
</SectionContainer>
<SectionContainer
title="HTTP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.http && hasResources.http ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`http-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-http-${i}`}
linkTo={`/http/${i}`}
{...overview.http[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer
title="HTTP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.http && hasResources.http ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`http-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-http-${i}`}
linkTo={`/http/${i}`}
{...overview.http[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer
title="TCP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.tcp && hasResources.tcp ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`tcp-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-tcp-${i}`}
linkTo={`/tcp/${i}`}
{...overview.tcp[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer
title="TCP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.tcp && hasResources.tcp ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`tcp-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-tcp-${i}`}
linkTo={`/tcp/${i}`}
{...overview.tcp[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer
title="UDP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.udp && hasResources.udp ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`udp-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-udp-${i}`}
linkTo={`/udp/${i}`}
{...overview.udp[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer
title="UDP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{overview?.udp && hasResources.udp ? (
RESOURCES.map((i) => (
<TraefikResourceStatsCard
key={`udp-${i}`}
title={capitalizeFirstLetter(i)}
data-testid={`section-udp-${i}`}
linkTo={`/udp/${i}`}
{...overview.udp[i]}
/>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
<SectionContainer title="Features">
{features.length
? features.map((i, idx) => {
return <FeatureCard key={`feature-${idx}`} feature={i} />
})
: null}
</SectionContainer>
<SectionContainer title="Features">
{features.length
? features.map((i, idx) => {
return <FeatureCard key={`feature-${idx}`} feature={i} />
})
: null}
</SectionContainer>
<SectionContainer title="Providers">
{overview?.providers?.length ? (
overview.providers.map((p, idx) => (
<Card key={`provider-${idx}`} css={{ height: 125 }}>
<Flex direction="column" align="center" gap={3} justify="center" css={{ height: '100%' }}>
<ProviderIcon name={p} size={52} />
<Text css={{ fontSize: '$4', fontWeight: 500, textAlign: 'center' }}>{p}</Text>
</Flex>
</Card>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
</Flex>
</Page>
<SectionContainer title="Providers">
{overview?.providers?.length ? (
overview.providers.map((p, idx) => (
<Card key={`provider-${idx}`} css={{ height: 125 }}>
<Flex direction="column" align="center" gap={3} justify="center" css={{ height: '100%' }}>
<ProviderIcon name={p} size={52} />
<Text css={{ fontSize: '$4', fontWeight: 500, textAlign: 'center' }}>{p}</Text>
</Flex>
</Card>
))
) : (
<Text size={4}>No related objects to show.</Text>
)}
</SectionContainer>
</Flex>
)
}
export const DashboardSkeleton = () => {
return (
<Page>
<Flex direction="column" gap={6}>
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
{[...Array(5)].map((_, i) => (
<FeatureCardSkeleton key={`entry-skeleton-${i}`} />
))}
</SectionContainer>
<Flex direction="column" gap={6}>
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
{[...Array(5)].map((_, i) => (
<FeatureCardSkeleton key={`entry-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="HTTP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`http-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="HTTP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`http-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="TCP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`tcp-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="TCP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`tcp-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="UDP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`udp-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer
title="UDP"
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
>
{[...Array(3)].map((_, i) => (
<StatsCardSkeleton key={`udp-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer title="Features">
{[...Array(3)].map((_, i) => (
<FeatureCardSkeleton key={`feature-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer title="Features">
{[...Array(3)].map((_, i) => (
<FeatureCardSkeleton key={`feature-skeleton-${i}`} />
))}
</SectionContainer>
<SectionContainer title="Providers">
{[...Array(3)].map((_, i) => (
<FeatureCardSkeleton key={`provider-skeleton-${i}`} />
))}
</SectionContainer>
</Flex>
</Page>
<SectionContainer title="Providers">
{[...Array(3)].map((_, i) => (
<FeatureCardSkeleton key={`provider-skeleton-${i}`} />
))}
</SectionContainer>
</Flex>
)
}

View file

@ -7,6 +7,7 @@ describe('<HttpMiddlewarePage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
{ route: '/http/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<HttpMiddlewarePage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
{ route: '/http/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<HttpMiddlewarePage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<HttpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/http/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -53,6 +56,7 @@ describe('<HttpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
{ route: '/http/middlewares/middleware-simple', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -99,6 +103,7 @@ describe('<HttpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
{ route: '/http/middlewares/middleware-plugin', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -338,6 +343,7 @@ describe('<HttpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
{ route: '/http/middlewares/middleware-complex', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -459,6 +465,7 @@ describe('<HttpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
{ route: '/http/middlewares/middleware-plugin-no-type', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))

View file

@ -1,11 +1,11 @@
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 Page from 'layout/Page'
import { NotFound } from 'pages/NotFound'
import breakpoints from 'utils/breakpoints'
@ -27,23 +27,29 @@ type HttpMiddlewareRenderProps = {
export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Skeleton css={{ height: '$7', width: '320px', mb: '$4' }} data-testid="skeleton" />
<MiddlewareGrid data-testid="skeletons">
<DetailSectionSkeleton />
</MiddlewareGrid>
<UsedByRoutersSkeleton />
</Page>
</>
)
}
@ -52,7 +58,10 @@ export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRender
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<H1 css={{ mb: '$7' }}>{data.name}</H1>
<MiddlewareGrid>
<Card css={{ p: '$3' }} data-testid="middleware-card">
@ -60,7 +69,7 @@ export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRender
</Card>
</MiddlewareGrid>
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="http" />
</Page>
</>
)
}

View file

@ -76,10 +76,13 @@ describe('<HttpMiddlewaresPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<HttpMiddlewaresPage />)
const { container, getByTestId } = renderWithProviders(<HttpMiddlewaresPage />, {
route: '/http/middlewares',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('HTTP Middlewares page')).toBeInTheDocument()
expect(getByTestId('/http/middlewares page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(5)
@ -120,6 +123,7 @@ describe('<HttpMiddlewaresPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/http/middlewares', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -14,7 +15,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
import { parseMiddlewareType } from 'libs/parsers'
export const makeRowRender = (): RenderRowType => {
@ -109,7 +109,10 @@ export const HttpMiddlewares = () => {
)
return (
<Page title="HTTP Middlewares">
<>
<Helmet>
<title>HTTP Middlewares - Traefik Proxy</title>
</Helmet>
<TableFilter />
<HttpMiddlewaresRender
error={error}
@ -120,6 +123,6 @@ export const HttpMiddlewares = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -10,6 +10,7 @@ describe('<HttpRouterPage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<HttpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
{ route: '/http/routers/mock-router', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -17,6 +18,7 @@ describe('<HttpRouterPage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<HttpRouterRender name="mock-router" data={undefined} error={undefined} />,
{ route: '/http/routers/mock-router', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -24,6 +26,7 @@ describe('<HttpRouterPage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<HttpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/http/routers/mock-router', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -40,6 +43,7 @@ describe('<HttpRouterPage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
{ route: '/http/routers/orphan-router@file', withPage: true },
)
const routerStructure = getByTestId('router-structure')

View file

@ -1,5 +1,6 @@
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'
@ -9,7 +10,6 @@ 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 Page from 'layout/Page'
import { getErrorData, getValidData } from 'libs/objectHandlers'
import { parseMiddlewareType } from 'libs/parsers'
import { NotFound } from 'pages/NotFound'
@ -105,17 +105,23 @@ type HttpRouterRenderProps = {
export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
<CardListSection bigDescription />
<CardListSection />
@ -127,7 +133,7 @@ export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) =
<DetailSectionSkeleton />
<DetailSectionSkeleton />
</SpacedColumns>
</Page>
</>
)
}
@ -136,10 +142,13 @@ export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) =
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<RouterStructure data={data} protocol="http" />
<RouterDetail data={data} />
</Page>
</>
)
}

View file

@ -49,10 +49,13 @@ describe('<HttpRoutersPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<HttpRoutersPage />)
const { container, getByTestId } = renderWithProviders(<HttpRoutersPage />, {
route: '/http/routers',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('HTTP Routers page')).toBeInTheDocument()
expect(getByTestId('/http/routers page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(4)
@ -100,6 +103,7 @@ describe('<HttpRoutersPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/http/routers', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
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'
@ -16,7 +17,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (protocol = 'http'): RenderRowType => {
const HttpRoutersRenderRow = (row) => (
@ -130,7 +130,10 @@ export const HttpRouters = () => {
)
return (
<Page title="HTTP Routers">
<>
<Helmet>
<title>HTTP Routers - Traefik Proxy</title>
</Helmet>
<TableFilter />
<HttpRoutersRender
error={error}
@ -141,6 +144,6 @@ export const HttpRouters = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -7,6 +7,7 @@ describe('<HttpServicePage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<HttpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
{ route: '/http/services/mock-service', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<HttpServicePage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<HttpServiceRender name="mock-service" data={undefined} error={undefined} />,
{ route: '/http/services/mock-service', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<HttpServicePage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<HttpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/http/services/mock-service', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -71,6 +74,7 @@ describe('<HttpServicePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/http/services/mock-service', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -142,6 +146,7 @@ describe('<HttpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/http/services/mock-service', withPage: true },
)
const healthCheck = getByTestId('health-check')
@ -196,6 +201,7 @@ describe('<HttpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/http/services/mock-service', withPage: true },
)
const mirrorServices = getByTestId('mirror-services')

View file

@ -1,5 +1,6 @@
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'
@ -18,7 +19,6 @@ 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 Page from 'layout/Page'
import { NotFound } from 'pages/NotFound'
type DetailProps = {
@ -270,17 +270,23 @@ type HttpServiceRenderProps = {
export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
<SpacedColumns>
<DetailSectionSkeleton narrow />
@ -288,7 +294,7 @@ export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps)
<DetailSectionSkeleton narrow />
</SpacedColumns>
<UsedByRoutersSkeleton />
</Page>
</>
)
}
@ -297,11 +303,14 @@ export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps)
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<H1 css={{ mb: '$7' }}>{data.name}</H1>
<ServicePanels data={data} protocol="http" />
<UsedByRoutersSection data={data} protocol="http" />
</Page>
</>
)
}

View file

@ -49,10 +49,13 @@ describe('<HttpServicesPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<HttpServicesPage />)
const { container, getByTestId } = renderWithProviders(<HttpServicesPage />, {
route: '/http/services',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('HTTP Services page')).toBeInTheDocument()
expect(getByTestId('/http/services page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(4)
@ -92,6 +95,7 @@ describe('<HttpServicesPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/http/services', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -14,7 +15,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (): RenderRowType => {
const HttpServicesRenderRow = (row) => (
@ -108,7 +108,10 @@ export const HttpServices = () => {
)
return (
<Page title="HTTP Services">
<>
<Helmet>
<title>HTTP Services - Traefik Proxy</title>
</Helmet>
<TableFilter />
<HttpServicesRender
error={error}
@ -119,6 +122,6 @@ export const HttpServices = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -0,0 +1,204 @@
import { waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import HubDashboard, { resetCache } from './HubDashboard'
import verifySignature from './workers/scriptVerification'
import { renderWithProviders } from 'utils/test'
vi.mock('./workers/scriptVerification', () => ({
default: vi.fn(),
}))
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom')
return {
...actual,
useParams: vi.fn(() => ({ id: 'test-id' })),
}
})
vi.mock('hooks/use-theme', () => ({
useIsDarkMode: vi.fn(() => false),
useTheme: vi.fn(() => ({
selectedTheme: 'light',
appliedTheme: 'light',
setTheme: vi.fn(),
})),
}))
describe('HubDashboard demo', () => {
const mockVerifyScriptSignature = vi.mocked(verifySignature)
let mockCreateObjectURL: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
// Mock URL.createObjectURL
mockCreateObjectURL = vi.fn(() => 'blob:mock-url')
globalThis.URL.createObjectURL = mockCreateObjectURL
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('without cache', () => {
beforeEach(() => {
// Reset cache before each test suites
resetCache()
})
it('should render loading state during script verification', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve({ verified: true, scriptContent: mockScriptContent }), 100),
),
)
const { getByTestId } = renderWithProviders(<HubDashboard path="dashboard" />, {
route: '/hub-dashboard',
})
expect(getByTestId('loading')).toBeInTheDocument()
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
})
it('should render the custom web component when signature is verified', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockResolvedValue({ verified: true, scriptContent: mockScriptContent })
const { container } = renderWithProviders(<HubDashboard path="dashboard" />, {
route: '/hub-dashboard',
})
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
const hubComponent = container.querySelector('hub-ui-demo-app')
expect(hubComponent).toBeInTheDocument()
expect(hubComponent?.getAttribute('path')).toBe('dashboard')
expect(hubComponent?.getAttribute('baseurl')).toBe('#/hub-dashboard')
expect(hubComponent?.getAttribute('theme')).toBe('light')
})
it('should render error state when signature verification fails', async () => {
mockVerifyScriptSignature.mockResolvedValue({ verified: false })
const { container } = renderWithProviders(<HubDashboard path="dashboard" />)
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
expect(container.textContent).toContain("Oops! We couldn't load the demo content")
const errorImage = container.querySelector('img[src="/img/gopher-something-went-wrong.png"]')
expect(errorImage).toBeInTheDocument()
const links = container.querySelectorAll('a')
const websiteLink = Array.from(links).find((link) => link.href.includes('traefik.io/traefik-hub'))
const docLink = Array.from(links).find((link) => link.href.includes('doc.traefik.io/traefik-hub'))
expect(websiteLink).toBeInTheDocument()
expect(docLink).toBeInTheDocument()
})
it('should render error state when verification throws an error', async () => {
mockVerifyScriptSignature.mockRejectedValue(new Error('Network error'))
const { container } = renderWithProviders(<HubDashboard path="dashboard" />)
await waitFor(() => {
expect(container.textContent).toContain("Oops! We couldn't load the demo content")
})
})
it('should call verifyScriptSignature with correct parameters', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockResolvedValue({ verified: true, scriptContent: mockScriptContent })
renderWithProviders(<HubDashboard path="dashboard" />)
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledWith(
'https://assets.traefik.io/hub-ui-demo.js',
'https://assets.traefik.io/hub-ui-demo.js.sig',
)
})
})
it('should set theme attribute based on dark mode', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockResolvedValue({ verified: true, scriptContent: mockScriptContent })
const { container } = renderWithProviders(<HubDashboard path="dashboard" />)
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
const hubComponent = container.querySelector('hub-ui-demo-app')
expect(hubComponent?.getAttribute('theme')).toMatch(/light|dark/)
})
it('should handle path with :id parameter correctly', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockResolvedValue({ verified: true, scriptContent: mockScriptContent })
const { container } = renderWithProviders(<HubDashboard path="gateways:id" />)
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
const hubComponent = container.querySelector('hub-ui-demo-app')
expect(hubComponent).toBeInTheDocument()
expect(hubComponent?.getAttribute('path')).toBe('gateways/test-id')
})
})
describe('with cache', () => {
beforeEach(() => {
resetCache()
})
it('should use cached blob URL without calling verifySignature again', async () => {
const mockScriptContent = new ArrayBuffer(100)
mockVerifyScriptSignature.mockResolvedValue({ verified: true, scriptContent: mockScriptContent })
// First render
const { container: firstContainer, unmount: firstUnmount } = renderWithProviders(
<HubDashboard path="dashboard" />,
)
await waitFor(() => {
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(1)
})
const firstHubComponent = firstContainer.querySelector('hub-ui-demo-app')
expect(firstHubComponent).toBeInTheDocument()
firstUnmount()
mockVerifyScriptSignature.mockClear()
// Second render - should use cache
const { container: secondContainer } = renderWithProviders(<HubDashboard path="dashboard" />)
await waitFor(() => {
const secondHubComponent = secondContainer.querySelector('hub-ui-demo-app')
expect(secondHubComponent).toBeInTheDocument()
})
expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(0)
})
})
})

View file

@ -0,0 +1,147 @@
import { Box, Flex, Image, Link, Text } from '@traefiklabs/faency'
import { useMemo, useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import verifySignature from './workers/scriptVerification'
import { SpinnerLoader } from 'components/SpinnerLoader'
import { useIsDarkMode } from 'hooks/use-theme'
import { TopNav } from 'layout/Navigation'
const SCRIPT_URL = 'https://assets.traefik.io/hub-ui-demo.js'
// Module-level cache to persist across component mount/unmount
let cachedBlobUrl: string | null = null
// Export a function to reset the cache (for testing)
export const resetCache = () => {
cachedBlobUrl = null
}
const HubDashboard = ({ path }: { path: string }) => {
const isDarkMode = useIsDarkMode()
const [scriptError, setScriptError] = useState<boolean | undefined>(undefined)
const [signatureVerified, setSignatureVerified] = useState(false)
const [verificationInProgress, setVerificationInProgress] = useState(false)
const [scriptBlobUrl, setScriptBlobUrl] = useState<string | null>(null)
const { id } = useParams()
const usedPath = useMemo(() => {
if (path?.includes(':id')) {
const splitted = path.split(':')
return `${splitted[0]}/${id}`
}
return path
}, [id, path])
useEffect(() => {
const verifyAndLoadScript = async () => {
setVerificationInProgress(true)
try {
const { verified, scriptContent: content } = await verifySignature(SCRIPT_URL, `${SCRIPT_URL}.sig`)
if (!verified || !content) {
setScriptError(true)
setVerificationInProgress(false)
} else {
setScriptError(false)
const blob = new Blob([content], { type: 'application/javascript' })
cachedBlobUrl = URL.createObjectURL(blob)
setScriptBlobUrl(cachedBlobUrl)
setSignatureVerified(true)
setVerificationInProgress(false)
}
} catch {
setScriptError(true)
setVerificationInProgress(false)
}
}
if (!cachedBlobUrl) {
verifyAndLoadScript()
} else {
setScriptBlobUrl(cachedBlobUrl)
setSignatureVerified(true)
}
}, [])
if (scriptError && !verificationInProgress) {
return (
<Flex gap={4} align="center" justify="center" direction="column" css={{ width: '100%', mt: '$8', maxWidth: 690 }}>
<Image src="/img/gopher-something-went-wrong.png" width={400} />
<Text css={{ fontSize: 24, fontWeight: '$semiBold' }}>Oops! We couldn't load the demo content.</Text>
<Text size={6} css={{ textAlign: 'center', lineHeight: 1.4 }}>
Don't worry — you can still learn more about{' '}
<Text size={6} css={{ fontWeight: '$semiBold' }}>
Traefik Hub API Management
</Text>{' '}
on our{' '}
<Link
href="https://traefik.io/traefik-hub?utm_campaign=hub-demo&utm_source=proxy-button&utm_medium=in-product"
target="_blank"
>
website
</Link>{' '}
or in our{' '}
<Link href="https://doc.traefik.io/traefik-hub/" target="_blank">
documentation
</Link>
.
</Text>
</Flex>
)
}
return (
<Box css={{ width: '100%' }}>
<Helmet>
<title>Hub Demo - Traefik Proxy</title>
<meta
httpEquiv="Content-Security-Policy"
content="script-src 'self' blob: 'unsafe-inline'; object-src 'none'; base-uri 'self';"
/>
{signatureVerified && scriptBlobUrl && <script src={scriptBlobUrl} type="module"></script>}
</Helmet>
<Box
css={{
margin: 'auto',
position: 'relative',
maxWidth: '1334px',
'@media (max-width:1440px)': {
maxWidth: '100%',
},
}}
>
<TopNav noHubButton css={{ position: 'absolute', top: 125, right: '$5' }} />
</Box>
{verificationInProgress ? (
<Box css={{ width: '100%', justifyItems: 'center', mt: '$8' }}>
<SpinnerLoader size={48} />
</Box>
) : (
<hub-ui-demo-app
key={usedPath}
path={usedPath}
theme={isDarkMode ? 'dark' : 'light'}
baseurl="#/hub-dashboard"
containercss={JSON.stringify({
maxWidth: '1334px',
'@media (max-width:1440px)': {
maxWidth: '100%',
},
margin: 'auto',
marginTop: '90px',
})}
></hub-ui-demo-app>
)}
</Box>
)
}
export default HubDashboard

View file

@ -0,0 +1,84 @@
import { Badge, Box, Flex, Text } from '@traefiklabs/faency'
import { useContext, useState } from 'react'
import { BsChevronRight } from 'react-icons/bs'
import { HubDemoContext } from './demoNavContext'
import { HubIcon } from './icons'
import Tooltip from 'components/Tooltip'
import { BasicNavigationItem, LAPTOP_BP } from 'layout/Navigation'
const ApimDemoNavMenu = ({
isResponsive,
isSmallScreen,
isExpanded,
}: {
isResponsive: boolean
isSmallScreen: boolean
isExpanded: boolean
}) => {
const [isCollapsed, setIsCollapsed] = useState(false)
const { navigationItems: hubDemoNavItems } = useContext(HubDemoContext)
if (!hubDemoNavItems) {
return null
}
return (
<Flex direction="column" css={{ borderTop: '1px solid $colors$tableRowBorder', borderRadius: 0, pt: '$3' }}>
<Flex
align="center"
css={{ color: '$grayBlue9', my: '$2', cursor: 'pointer' }}
onClick={() => setIsCollapsed(!isCollapsed)}
>
<BsChevronRight
size={12}
style={{
transform: isCollapsed ? 'rotate(90deg)' : 'unset',
transition: 'transform 0.3s ease-in-out',
}}
/>
{isSmallScreen ? (
<Tooltip label="Hub demo">
<Box css={{ ml: 4, color: '$navButtonText' }}>
<HubIcon width={20} />
</Box>
</Tooltip>
) : (
<>
<Text
css={{
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: 0.2,
color: '$grayBlue9',
ml: 8,
[`@media (max-width:${LAPTOP_BP}px)`]: isResponsive ? { display: 'none' } : undefined,
}}
>
API management
</Text>
<Badge variant="green" css={{ ml: '$2' }}>
Demo
</Badge>
</>
)}
</Flex>
<Box
css={{ mt: '$1', transition: 'max-height 0.3s ease-out', maxHeight: isCollapsed ? 500 : 0, overflow: 'hidden' }}
>
{hubDemoNavItems.map((route, idx) => (
<BasicNavigationItem
key={`apim-${idx}`}
route={route}
isSmallScreen={isSmallScreen}
isExpanded={isExpanded}
/>
))}
</Box>
</Flex>
)
}
export default ApimDemoNavMenu

View file

@ -0,0 +1,15 @@
import { createContext } from 'react'
import { RouteObject } from 'react-router-dom'
import { useHubDemo } from './use-hub-demo'
export const HubDemoContext = createContext<{
routes: RouteObject[] | null
navigationItems: HubDemo.NavItem[] | null
}>({ routes: null, navigationItems: null })
export const HubDemoProvider = ({ basePath, children }) => {
const { routes, navigationItems } = useHubDemo(basePath)
return <HubDemoContext.Provider value={{ routes, navigationItems }}>{children}</HubDemoContext.Provider>
}

21
webui/src/pages/hub-demo/hub-demo.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
namespace HubDemo {
interface Route {
path: string
label: string
icon: string
contentPath: string
dynamicSegments?: string[]
activeMatches?: string[]
}
interface Manifest {
routes: Route[]
}
interface NavItem {
path: string
label: string
icon: ReactNode
activeMatches?: string[]
}
}

View file

@ -0,0 +1,68 @@
import { Flex } from '@traefiklabs/faency'
import { useId } from 'react'
import { CustomIconProps } from 'components/icons'
const ApiIcon = ({ color = 'currentColor', css = {}, ...props }: CustomIconProps) => {
const linearGradient1Id = useId()
const linearGradient2Id = useId()
const linearGradient3Id = useId()
const titleId = useId()
return (
<Flex css={css}>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
{...props}
>
<title id={titleId}>apis</title>
<defs>
<linearGradient x1="71.828%" y1="92.873%" x2="35.088%" y2="3.988%" id={linearGradient1Id}>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} stopOpacity="0" offset="100%" />
</linearGradient>
<linearGradient x1="64.659%" y1="7.289%" x2="29.649%" y2="91.224%" id={linearGradient2Id}>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} stopOpacity="0" offset="100%" />
</linearGradient>
<linearGradient x1="10.523%" y1="76.967%" x2="87.277%" y2="76.967%" id={linearGradient3Id}>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} stopOpacity="0" offset="100%" />
</linearGradient>
</defs>
<g fill="none" fillRule="evenodd">
<path
d="M12.425 19.85a7.425 7.425 0 1 0 0-14.85 7.425 7.425 0 0 0 0 14.85z"
stroke={color}
strokeWidth="1.35"
opacity=".7"
strokeLinejoin="round"
/>
<path
d="M3.375 7.425c0-.411.061-.809.175-1.183l-1.22-.61a5.401 5.401 0 0 0 4.42 7.151V11.42a4.051 4.051 0 0 1-3.375-3.994z"
fill={`url(#${linearGradient1Id})`}
transform="translate(5 5)"
/>
<path
d="M8.1 11.419v1.364a5.401 5.401 0 0 0 4.42-7.15l-1.22.61a4.051 4.051 0 0 1-3.2 5.177z"
fill={`url(#${linearGradient2Id})`}
transform="translate(5 5)"
/>
<path
d="M7.425 3.375a4.044 4.044 0 0 0-3.27 1.66l-1.22-.61a5.395 5.395 0 0 1 4.49-2.4c1.872 0 3.522.953 4.49 2.4l-1.22.61a4.044 4.044 0 0 0-3.27-1.66z"
fill={`url(#${linearGradient3Id})`}
transform="translate(5 5)"
/>
<path d="M12.425 14.45a2.025 2.025 0 1 0 0-4.05 2.025 2.025 0 0 0 0 4.05z" fill={color} fillRule="nonzero" />
</g>
</svg>
</Flex>
)
}
export default ApiIcon

View file

@ -0,0 +1,28 @@
import { Flex } from '@traefiklabs/faency'
import { CustomIconProps } from 'components/icons'
const DashboardIcon = ({ color = 'currentColor', css = {}, ...props }: CustomIconProps) => {
return (
<Flex css={css}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="dashboard-icon"
{...props}
>
<title id="dashboard-icon">dashboard</title>
<g stroke={color} strokeWidth="1.35" fill="none" fillRule="evenodd" strokeLinejoin="round">
<path opacity=".7" d="M11.075 5H5v6.075h6.075z" />
<path d="M19.85 5h-6.075v6.075h6.075zM11.075 13.775H5v6.075h6.075z" />
<path opacity=".7" d="M19.85 13.775h-6.075v6.075h6.075z" />
</g>
</svg>
</Flex>
)
}
export default DashboardIcon

View file

@ -0,0 +1,69 @@
import { Flex } from '@traefiklabs/faency'
import { useId } from 'react'
import { CustomIconProps } from 'components/icons'
const GatewayIcon = ({ color = 'currentColor', css = {}, ...props }: CustomIconProps) => {
const titleId = useId()
return (
<Flex css={css}>
<svg
width="48px"
height="48px"
viewBox="0 0 48 48"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
{...props}
>
<title id={titleId}>gateways_icon</title>
<defs>
<linearGradient x1="50%" y1="100%" x2="50%" y2="-30.9671875%" id="linearGradient-1">
<stop stopColor={color} stopOpacity="0.322497815" offset="0%"></stop>
<stop stopColor={color} stopOpacity="0" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="100%" x2="50%" y2="-30.9671875%" id="linearGradient-2">
<stop stopColor={color} stopOpacity="0.322497815" offset="0%"></stop>
<stop stopColor={color} stopOpacity="0" offset="100%"></stop>
</linearGradient>
<linearGradient x1="100%" y1="50%" x2="-5.3329563e-11%" y2="50%" id={`linearGradient-3-${titleId}`}>
<stop stopColor={color} offset="0%"></stop>
<stop stopColor={color} stopOpacity="0.2" offset="100%"></stop>
</linearGradient>
</defs>
<g id={titleId} stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="Dashboard-Component-States" transform="translate(-819, -59)">
<g id="Icon" transform="translate(823, 67)">
<path
d="M11.8810613,8.00728271 L11.8810613,21.2735224 L20.1151876,25.72 L28.3494968,21.2735224 L28.3494968,8.00728271 C28.3494968,8.00728271 25.6047575,5.72 20.115279,5.72 C14.6258005,5.72 11.8810613,8.00728271 11.8810613,8.00728271 Z"
id="Rectangle-37"
fill="url(#linearGradient-2)"
></path>
<path
d="M15.1747484,11.0923696 L15.1747484,19.0521134 L20.1152242,21.72 L25.0558097,19.0521134 L25.0558097,11.0923696 C25.0558097,11.0923696 23.4089661,9.72 20.115279,9.72 C16.8215919,9.72 15.1747484,11.0923696 15.1747484,11.0923696 Z"
id="Rectangle-37"
fill={color}
></path>
<path
d="M10.65,2.32629257 L10.65,29.6737074 L1.35,24.3594217 L1.35,7.64057828 L10.65,2.32629257 Z"
id="Rectangle"
stroke={`url(#linearGradient-3-${titleId})`}
strokeWidth="2.7"
></path>
<path
d="M38.65,2.32629257 L38.65,29.6737074 L29.35,24.3594217 L29.35,7.64057828 L38.65,2.32629257 Z"
id="Rectangle"
stroke={`url(#linearGradient-3-${titleId})`}
strokeWidth="2.7"
transform="translate(34, 16) scale(-1, 1) translate(-34, -16)"
></path>
</g>
</g>
</g>
</svg>
</Flex>
)
}
export default GatewayIcon

View file

@ -0,0 +1,18 @@
import { CustomIconProps } from 'components/icons'
const Hub = (props: CustomIconProps) => {
const { color = 'currentColor', ...restProps } = props
return (
<svg xmlns="http://www.w3.org/2000/svg" {...restProps} viewBox="0 0 128 116">
<g>
<path
fill={color}
d="M66.315.61l29.558 16.035a5.084 5.084 0 012.653 4.476l-.025 25.835 26.846 14.565A5.084 5.084 0 01128 65.997l-.029 28.824a5.084 5.084 0 01-2.643 4.46l-29.53 16.104a5.029 5.029 0 01-4.857-.021l-26.737-14.896-27.353 14.917a5.029 5.029 0 01-4.857-.021L2.604 98.99A5.085 5.085 0 010 94.547V66.06c0-1.86 1.01-3.571 2.635-4.461l26.839-14.706v-25.71c0-1.859 1.01-3.57 2.635-4.46L61.499.619a5.028 5.028 0 014.816-.01zm25.49 56.008L70.81 68.115a3.39 3.39 0 00-1.757 2.975v18.47a3.39 3.39 0 001.736 2.962l21.01 11.705a3.352 3.352 0 003.239.014l21.07-11.49a3.39 3.39 0 001.763-2.974l.018-18.731a3.39 3.39 0 00-1.768-2.984l-21.106-11.45a3.352 3.352 0 00-3.21.006zm-58.959-.001L11.862 68.115a3.39 3.39 0 00-1.757 2.974v18.47a3.39 3.39 0 001.736 2.963l21.011 11.705a3.352 3.352 0 003.239.014l21.07-11.49a3.39 3.39 0 001.763-2.974l.018-18.676a3.39 3.39 0 00-1.763-2.981L36.063 56.613a3.352 3.352 0 00-3.217.004zm29.478-44.878l-20.988 11.5a3.39 3.39 0 00-1.757 2.974V44.68a3.39 3.39 0 001.742 2.966l21.065 11.679a3.352 3.352 0 003.233.01l21.017-11.467a3.39 3.39 0 001.762-2.974l.018-18.723a3.39 3.39 0 00-1.77-2.984l-21.11-11.454a3.352 3.352 0 00-3.212.007z"
></path>
</g>
</svg>
)
}
export default Hub

View file

@ -0,0 +1,5 @@
export { default as ApiIcon } from './api'
export { default as DashboardIcon } from './dashboard'
export { default as GatewayIcon } from './gateway'
export { default as HubIcon } from './hub'
export { default as PortalIcon } from './portal'

View file

@ -0,0 +1,48 @@
import { Flex } from '@traefiklabs/faency'
import { useId } from 'react'
import { CustomIconProps } from 'components/icons'
const PortalIcon = ({ color = 'currentColor', css = {}, ...props }: CustomIconProps) => {
const linearGradientId = useId()
const titleId = useId()
return (
<Flex css={css}>
<svg
xmlns="http://www.w3.org/2000/svg"
aria-labelledby={titleId}
width="24"
height="24"
viewBox="0 0 24 24"
role="img"
{...props}
>
<title id={titleId}>portals</title>
<defs>
<linearGradient x1="50%" y1="17.22%" x2="50%" y2="100%" id={linearGradientId}>
<stop stopColor={color} offset="0%" />
<stop stopColor={color} stopOpacity=".468" offset="100%" />
</linearGradient>
</defs>
<g fill="none" fillRule="evenodd">
<path
stroke={`url(#${linearGradientId})`}
strokeWidth="1.35"
strokeLinejoin="round"
d="M14.85 0H0v13.5h14.85z"
transform="translate(5 5)"
/>
<path
d="M7.7 7.7a.675.675 0 1 0 0-1.35.675.675 0 0 0 0 1.35zM10.4 7.7a.675.675 0 1 0 0-1.35.675.675 0 0 0 0 1.35z"
fill={color}
fillRule="nonzero"
/>
<path stroke={color} strokeWidth="1.35" strokeLinejoin="round" d="M5 9.05h14.85" />
</g>
</svg>
</Flex>
)
}
export default PortalIcon

View file

@ -0,0 +1,301 @@
import { renderHook, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useHubDemo } from './use-hub-demo'
import verifySignature from './workers/scriptVerification'
vi.mock('./workers/scriptVerification', () => ({
default: vi.fn(),
}))
const MOCK_ROUTES_MANIFEST = {
routes: [
{
path: '/dashboard',
label: 'Dashboard',
icon: 'dashboard',
contentPath: 'dashboard',
},
{
path: '/gateway',
label: 'Gateway',
icon: 'gateway',
contentPath: 'gateway',
dynamicSegments: [':id'],
activeMatches: ['/gateway/:id'],
},
],
}
describe('useHubDemo', () => {
const mockVerifySignature = vi.mocked(verifySignature)
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
const setupMockVerification = (manifest: HubDemo.Manifest) => {
const encoder = new TextEncoder()
const mockScriptContent = encoder.encode(JSON.stringify(manifest))
mockVerifySignature.mockResolvedValue({
verified: true,
scriptContent: mockScriptContent.buffer,
})
}
describe('basic functions', () => {
const mockVerifySignature = vi.mocked(verifySignature)
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
it('should return null when signature verification fails', async () => {
mockVerifySignature.mockResolvedValue({
verified: false,
})
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(mockVerifySignature).toHaveBeenCalled()
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(result.current.routes).toBeNull()
expect(result.current.navigationItems).toBeNull()
})
it('should return null when scriptContent is missing', async () => {
mockVerifySignature.mockResolvedValue({
verified: true,
scriptContent: undefined,
})
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(mockVerifySignature).toHaveBeenCalled()
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(result.current.routes).toBeNull()
expect(result.current.navigationItems).toBeNull()
})
it('should handle errors during manifest fetch', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockVerifySignature.mockRejectedValue(new Error('Network error'))
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load hub demo manifest:', expect.any(Error))
})
expect(result.current.routes).toBeNull()
expect(result.current.navigationItems).toBeNull()
consoleErrorSpy.mockRestore()
})
it('should handle invalid JSON in manifest', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const encoder = new TextEncoder()
const invalidJson = encoder.encode('{ invalid json }')
mockVerifySignature.mockResolvedValue({
verified: true,
scriptContent: invalidJson.buffer,
})
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load hub demo manifest:', expect.any(Error))
})
expect(result.current.routes).toBeNull()
expect(result.current.navigationItems).toBeNull()
consoleErrorSpy.mockRestore()
})
})
describe('routes generation', () => {
it('should generate routes with correct base path', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.routes).not.toBeNull()
})
expect(result.current.routes).toHaveLength(3)
expect(result.current.routes![0].path).toBe('/hub/dashboard')
expect(result.current.routes![1].path).toBe('/hub/gateway')
expect(result.current.routes![2].path).toBe('/hub/gateway/:id')
})
it('should generate routes for dynamic segments', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.routes).not.toBeNull()
})
expect(result.current.routes).toHaveLength(3)
expect(result.current.routes![0].path).toBe('/hub/dashboard')
expect(result.current.routes![1].path).toBe('/hub/gateway')
expect(result.current.routes![2].path).toBe('/hub/gateway/:id')
})
it('should render HubDashboard with correct contentPath for dynamic segments', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.routes).not.toBeNull()
})
const baseRoute = result.current.routes![1]
const dynamicRoute = result.current.routes![2]
expect(baseRoute.element).toBeDefined()
expect(dynamicRoute.element).toBeDefined()
const baseElement = baseRoute.element as ReactNode & { props?: { path: string } }
const dynamicElement = dynamicRoute.element as ReactNode & { props?: { path: string } }
expect((baseElement as { props: { path: string } }).props.path).toBe('gateway')
expect((dynamicElement as { props: { path: string } }).props.path).toBe('gateway:id')
})
it('should update routes when basePath changes', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result, rerender } = renderHook(({ basePath }) => useHubDemo(basePath), {
initialProps: { basePath: '/hub' },
})
await waitFor(() => {
expect(result.current.routes).not.toBeNull()
})
expect(result.current.routes![0].path).toBe('/hub/dashboard')
rerender({ basePath: '/demo' })
expect(result.current.routes![0].path).toBe('/demo/dashboard')
})
})
describe('navigation items generation', () => {
it('should generate navigation items with correct icons', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.navigationItems).not.toBeNull()
})
expect(result.current.navigationItems).toHaveLength(2)
expect(result.current.navigationItems![0].label).toBe('Dashboard')
expect(result.current.navigationItems![0].path).toBe('/hub/dashboard')
expect(result.current.navigationItems![0].icon).toBeDefined()
expect(result.current.navigationItems![1].label).toBe('Gateway')
})
it('should include activeMatches in navigation items', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.navigationItems).not.toBeNull()
})
expect(result.current.navigationItems![1].activeMatches).toEqual(['/hub/gateway/:id'])
})
it('should update navigation items when basePath changes', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result, rerender } = renderHook(({ basePath }) => useHubDemo(basePath), {
initialProps: { basePath: '/hub' },
})
await waitFor(() => {
expect(result.current.navigationItems).not.toBeNull()
})
expect(result.current.navigationItems![0].path).toBe('/hub/dashboard')
rerender({ basePath: '/demo' })
expect(result.current.navigationItems![0].path).toBe('/demo/dashboard')
})
it('should handle unknown icon types gracefully', async () => {
const manifestWithUnknownIcon: HubDemo.Manifest = {
routes: [
{
path: '/unknown',
label: 'Unknown',
icon: 'unknown-icon-type',
contentPath: 'unknown',
},
],
}
setupMockVerification(manifestWithUnknownIcon)
const { result } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.navigationItems).not.toBeNull()
})
expect(result.current.navigationItems![0].icon).toBeUndefined()
})
})
describe('memoization', () => {
it('should not regenerate routes when manifest and basePath are unchanged', async () => {
setupMockVerification(MOCK_ROUTES_MANIFEST)
const { result, rerender } = renderHook(() => useHubDemo('/hub'))
await waitFor(() => {
expect(result.current.routes).not.toBeNull()
})
const firstRoutes = result.current.routes
const firstNavItems = result.current.navigationItems
rerender()
expect(result.current.routes).toBe(firstRoutes)
expect(result.current.navigationItems).toBe(firstNavItems)
})
})
})

View file

@ -0,0 +1,89 @@
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { RouteObject } from 'react-router-dom'
import HubDashboard from 'pages/hub-demo/HubDashboard'
import { ApiIcon, DashboardIcon, GatewayIcon, PortalIcon } from 'pages/hub-demo/icons'
import verifySignature from 'pages/hub-demo/workers/scriptVerification'
const ROUTES_MANIFEST_URL = 'https://traefik.github.io/hub-ui-demo-app/config/routes.json'
const HUB_DEMO_NAV_ICONS: Record<string, ReactNode> = {
dashboard: <DashboardIcon color="currentColor" width={22} height={22} />,
gateway: <GatewayIcon color="currentColor" width={22} height={22} />,
api: <ApiIcon color="currentColor" width={22} height={22} />,
portal: <PortalIcon color="currentColor" width={22} height={22} />,
}
const useHubDemoRoutesManifest = (): HubDemo.Manifest | null => {
const [manifest, setManifest] = useState<HubDemo.Manifest | null>(null)
useEffect(() => {
const fetchManifest = async () => {
try {
const { verified, scriptContent } = await verifySignature(ROUTES_MANIFEST_URL, `${ROUTES_MANIFEST_URL}.sig`)
if (!verified || !scriptContent) {
setManifest(null)
return
}
const textDecoder = new TextDecoder()
const jsonString = textDecoder.decode(scriptContent)
const data: HubDemo.Manifest = JSON.parse(jsonString)
setManifest(data)
} catch (error) {
console.error('Failed to load hub demo manifest:', error)
setManifest(null)
}
}
fetchManifest()
}, [])
return manifest
}
export const useHubDemo = (basePath: string) => {
const manifest = useHubDemoRoutesManifest()
const routes = useMemo(() => {
if (!manifest) {
return null
}
const routeObjects: RouteObject[] = []
manifest.routes.forEach((route: HubDemo.Route) => {
routeObjects.push({
path: `${basePath}${route.path}`,
element: <HubDashboard path={route.contentPath} />,
})
if (route.dynamicSegments) {
route.dynamicSegments.forEach((segment) => {
routeObjects.push({
path: `${basePath}${route.path}/${segment}`,
element: <HubDashboard path={`${route.contentPath}${segment}`} />,
})
})
}
})
return routeObjects
}, [basePath, manifest])
const navigationItems = useMemo(() => {
if (!manifest) {
return null
}
return manifest.routes.map((route) => ({
path: `${basePath}${route.path}`,
label: route.label,
icon: HUB_DEMO_NAV_ICONS[route.icon],
activeMatches: route.activeMatches?.map((r) => `${basePath}${r}`),
}))
}, [basePath, manifest])
return { routes, navigationItems }
}

View file

@ -0,0 +1,156 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import verifySignature from './scriptVerification'
describe('Script Signature Verification - Integration Tests', () => {
let fetchMock: ReturnType<typeof vi.fn>
const SCRIPT_URL = 'https://example.com/script.js'
const SIGNATURE_URL = 'https://example.com/script.js.sig'
const TEST_PUBLIC_KEY = 'MCowBQYDK2VwAyEAWH71OHphISjNK3mizCR/BawiDxc6IXT1vFHpBcxSIA0='
const VALID_SCRIPT = "console.log('Hello from verified script!');"
const VALID_SIGNATURE_HEX =
'04c90fcd35caaf3cf4582a2767345f8cd9f6519e1ce79ebaeedbe0d5f671d762d1aa8ec258831557e2de0e47f224883f84eb5a0f22ec18eb7b8c48de3096d000'
const CORRUPTED_SCRIPT = "console.log('Malicious code injected!');"
beforeEach(() => {
vi.clearAllMocks()
fetchMock = vi.fn()
globalThis.fetch = fetchMock
})
it('should verify a valid script with correct signature through real worker', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(fetchMock).toHaveBeenCalledWith(SCRIPT_URL)
expect(fetchMock).toHaveBeenCalledWith(SIGNATURE_URL)
expect(result.verified).toBe(true)
expect(result.scriptContent).toBeDefined()
}, 15000)
it('should reject a corrupted script with mismatched signature', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(CORRUPTED_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(fetchMock).toHaveBeenCalledWith(SCRIPT_URL)
expect(fetchMock).toHaveBeenCalledWith(SIGNATURE_URL)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should reject script with invalid signature format', async () => {
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response('not-a-valid-signature', {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should reject script with wrong public key', async () => {
const WRONG_PUBLIC_KEY = 'MCowBQYDK2VwAyEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=='
fetchMock.mockImplementation((url: string) => {
if (url === SCRIPT_URL) {
return Promise.resolve(
new Response(VALID_SCRIPT, {
status: 200,
headers: { 'Content-Type': 'application/javascript' },
}),
)
}
if (url === SIGNATURE_URL) {
return Promise.resolve(
new Response(VALID_SIGNATURE_HEX, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
}),
)
}
return Promise.reject(new Error('Unexpected URL'))
})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, WRONG_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
}, 15000)
it('should handle network failures when fetching script', async () => {
fetchMock.mockImplementation(() =>
Promise.resolve(
new Response(null, {
status: 404,
statusText: 'Not Found',
}),
),
)
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const result = await verifySignature(SCRIPT_URL, SIGNATURE_URL, TEST_PUBLIC_KEY)
expect(result.verified).toBe(false)
expect(result.scriptContent).toBeUndefined()
expect(consoleErrorSpy).toHaveBeenCalled()
consoleErrorSpy.mockRestore()
}, 15000)
})

View file

@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import verifySignature from './scriptVerification'
class MockWorker {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: ((error: ErrorEvent) => void) | null = null
postMessage = vi.fn()
terminate = vi.fn()
simulateMessage(data: unknown) {
if (this.onmessage) {
this.onmessage(new MessageEvent('message', { data }))
}
}
simulateError(error: Error) {
if (this.onerror) {
this.onerror(new ErrorEvent('error', { error, message: error.message }))
}
}
}
describe('verifySignature', () => {
let mockWorkerInstance: MockWorker
let originalWorker: typeof Worker
beforeEach(() => {
vi.clearAllMocks()
originalWorker = globalThis.Worker
mockWorkerInstance = new MockWorker()
globalThis.Worker = class extends EventTarget {
constructor() {
super()
return mockWorkerInstance as any
}
} as any
})
afterEach(() => {
globalThis.Worker = originalWorker
vi.restoreAllMocks()
})
it('should return true when verification succeeds', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
expect(mockWorkerInstance.postMessage).toHaveBeenCalledWith(
expect.objectContaining({
scriptUrl: scriptPath,
signatureUrl: signaturePath,
requestId: expect.any(String),
}),
)
const mockScriptContent = new ArrayBuffer(100)
mockWorkerInstance.simulateMessage({
success: true,
verified: true,
error: null,
scriptContent: mockScriptContent,
})
const result = await promise
expect(result).toEqual({ verified: true, scriptContent: mockScriptContent })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
})
it('should return false when verification fails', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
mockWorkerInstance.simulateMessage({
success: false,
verified: false,
error: 'Signature verification failed',
})
const result = await promise
expect(result).toEqual({ verified: false })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith('Worker verification failed:', 'Signature verification failed')
consoleErrorSpy.mockRestore()
})
it('should return false when worker throws an error', async () => {
const scriptPath = 'https://example.com/script.js'
const signaturePath = 'https://example.com/script.js.sig'
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const promise = verifySignature(scriptPath, signaturePath)
await new Promise((resolve) => setTimeout(resolve, 0))
// Simulate worker onerror event
const error = new Error('Worker crashed')
mockWorkerInstance.simulateError(error)
const result = await promise
expect(result).toEqual({ verified: false })
expect(mockWorkerInstance.terminate).toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith('Worker error:', expect.any(ErrorEvent))
consoleErrorSpy.mockRestore()
})
})

View file

@ -0,0 +1,57 @@
export interface VerificationResult {
verified: boolean
scriptContent?: ArrayBuffer
}
const PUBLIC_KEY = 'MCowBQYDK2VwAyEAWMBZ0pMBaL/s8gNXxpAPCIQ8bxjnuz6bQFwGYvjXDfg='
async function verifySignature(
contentPath: string,
signaturePath: string,
publicKey: string = PUBLIC_KEY,
): Promise<VerificationResult> {
return new Promise((resolve) => {
const requestId = Math.random().toString(36).substring(2)
const worker = new Worker(new URL('./scriptVerificationWorker.ts', import.meta.url), { type: 'module' })
const timeout = setTimeout(() => {
worker.terminate()
console.error('Script verification timeout')
resolve({ verified: false })
}, 30000)
worker.onmessage = (event) => {
clearTimeout(timeout)
worker.terminate()
const { success, verified, error, scriptContent } = event.data
if (!success) {
console.error('Worker verification failed:', error)
resolve({ verified: false })
return
}
resolve({
verified: verified === true,
scriptContent: verified ? scriptContent : undefined,
})
}
worker.onerror = (error) => {
clearTimeout(timeout)
worker.terminate()
console.error('Worker error:', error)
resolve({ verified: false })
}
worker.postMessage({
requestId,
scriptUrl: contentPath,
signatureUrl: signaturePath,
publicKey,
})
})
}
export default verifySignature

View file

@ -0,0 +1,189 @@
// Script verification worker
// Runs in isolated context for secure verification
import { verify } from '@noble/ed25519'
import * as ed25519 from '@noble/ed25519'
import { sha512 } from '@noble/hashes/sha2.js'
// Set up SHA-512 for @noble/ed25519 v3.x
ed25519.hashes.sha512 = sha512
ed25519.hashes.sha512Async = (m) => Promise.resolve(sha512(m))
function base64ToArrayBuffer(base64: string): ArrayBuffer {
try {
// @ts-expect-error - fromBase64 is not yet in all TypeScript lib definitions
const bytes = Uint8Array.fromBase64(base64)
return bytes.buffer
} catch {
// Fallback for browsers without Uint8Array.fromBase64()
const binaryString = atob(base64)
const bytes = new Uint8Array(binaryString.length)
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i)
}
return bytes.buffer
}
}
function extractEd25519PublicKey(spkiBytes: Uint8Array): Uint8Array {
if (spkiBytes.length !== 44) {
throw new Error('Invalid SPKI length for Ed25519')
}
return spkiBytes.slice(-32)
}
async function importPublicKeyWebCrypto(publicKey: string): Promise<CryptoKey> {
const publicKeyBuffer = base64ToArrayBuffer(publicKey)
return await crypto.subtle.importKey(
'spki',
publicKeyBuffer,
{
name: 'Ed25519',
},
false,
['verify'],
)
}
async function verifyWithWebCrypto(
publicKey: string,
scriptBuffer: ArrayBuffer,
signatureBuffer: ArrayBuffer,
): Promise<boolean> {
try {
const cryptoPublicKey = await importPublicKeyWebCrypto(publicKey)
return await crypto.subtle.verify('Ed25519', cryptoPublicKey, signatureBuffer, scriptBuffer)
} catch (error) {
console.log('Web Crypto verification failed:', error instanceof Error ? error.message : 'Unknown error')
return false
}
}
function parseSignature(signatureBuffer: ArrayBuffer): Uint8Array {
const signatureBytes = new Uint8Array(signatureBuffer)
// If already 64 bytes, assume it's raw binary
if (signatureBytes.length === 64) {
return signatureBytes
}
// Try to parse as text (base64 or hex)
const signatureText = new TextDecoder().decode(signatureBytes).trim()
// base64 decoding
try {
const base64Decoded = new Uint8Array(base64ToArrayBuffer(signatureText))
if (base64Decoded.length === 64) {
return base64Decoded
}
} catch (e) {
console.error(e)
}
// hex decoding
if (signatureText.length === 128 && /^[0-9a-fA-F]+$/.test(signatureText)) {
try {
// @ts-expect-error - fromHex is not yet in all TypeScript lib definitions
return Uint8Array.fromHex(signatureText)
} catch {
// Fallback for browsers without Uint8Array.fromHex()
const hexDecoded = new Uint8Array(64)
for (let i = 0; i < 64; i++) {
hexDecoded[i] = parseInt(signatureText.slice(i * 2, i * 2 + 2), 16)
}
return hexDecoded
}
}
throw new Error(`Unable to parse signature format.`)
}
async function verifyWithNoble(
publicKey: string,
scriptBuffer: ArrayBuffer,
signatureBuffer: ArrayBuffer,
): Promise<boolean> {
try {
const publicKeySpki = new Uint8Array(base64ToArrayBuffer(publicKey))
const publicKeyRaw = extractEd25519PublicKey(publicKeySpki)
const scriptBytes = new Uint8Array(scriptBuffer)
const signatureBytes = parseSignature(signatureBuffer)
return verify(signatureBytes, scriptBytes, publicKeyRaw)
} catch (error) {
console.log('Noble verification failed:', error instanceof Error ? error.message : 'Unknown error')
return false
}
}
self.onmessage = async function (event) {
const { requestId, scriptUrl, signatureUrl, publicKey } = event.data
try {
const [scriptResponse, signatureResponse] = await Promise.all([fetch(scriptUrl), fetch(signatureUrl)])
if (!scriptResponse.ok || !signatureResponse.ok) {
self.postMessage({
requestId,
success: false,
verified: false,
error: `Failed to fetch files. Script: ${scriptResponse.status} ${scriptResponse.statusText}, Signature: ${signatureResponse.status} ${signatureResponse.statusText}`,
})
return
}
const [scriptBuffer, signatureBuffer] = await Promise.all([
scriptResponse.arrayBuffer(),
signatureResponse.arrayBuffer(),
])
// Try Web Crypto API first, fallback to Noble if it fails
let verified = await verifyWithWebCrypto(publicKey, scriptBuffer, signatureBuffer)
if (!verified) {
verified = await verifyWithNoble(publicKey, scriptBuffer, signatureBuffer)
}
// If verified, include script content to avoid re-downloading
let scriptContent: ArrayBuffer | undefined
if (verified) {
scriptContent = scriptBuffer
}
// Send message with transferable ArrayBuffer for efficiency
const message = {
requestId,
success: true,
verified,
scriptSize: scriptBuffer.byteLength,
signatureSize: signatureBuffer.byteLength,
scriptContent,
}
if (scriptContent) {
self.postMessage(message, { transfer: [scriptContent] })
} else {
self.postMessage(message)
}
} catch (error) {
console.error('[Worker] Verification error:', error)
self.postMessage({
requestId,
success: false,
verified: false,
error: error instanceof Error ? error.message : 'Unknown error',
})
}
}
self.onerror = function (error) {
console.error('[Worker] Worker error:', error)
self.postMessage({
success: false,
verified: false,
error,
})
}

View file

@ -7,6 +7,7 @@ describe('<TcpMiddlewarePage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<TcpMiddlewarePage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<TcpMiddlewarePage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<TcpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/tcp/middlewares/mock-middleware', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -53,6 +56,7 @@ describe('<TcpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
{ route: '/tcp/middlewares/middleware-simple', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -103,6 +107,7 @@ describe('<TcpMiddlewarePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
{ route: '/tcp/middlewares/middleware-complex', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))

View file

@ -1,11 +1,11 @@
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 Page from 'layout/Page'
import { NotFound } from 'pages/NotFound'
import breakpoints from 'utils/breakpoints'
@ -27,23 +27,29 @@ type TcpMiddlewareRenderProps = {
export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Skeleton css={{ height: '$7', width: '320px', mb: '$4' }} data-testid="skeleton" />
<MiddlewareGrid>
<DetailSectionSkeleton />
</MiddlewareGrid>
<UsedByRoutersSkeleton />
</Page>
</>
)
}
@ -52,7 +58,10 @@ export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderPr
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<H1 css={{ mb: '$7' }}>{data.name}</H1>
<MiddlewareGrid>
<Card css={{ padding: '$5' }} data-testid="middleware-card">
@ -60,7 +69,7 @@ export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderPr
</Card>
</MiddlewareGrid>
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="tcp" />
</Page>
</>
)
}

View file

@ -29,10 +29,13 @@ describe('<TcpMiddlewaresPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<TcpMiddlewaresPage />)
const { container, getByTestId } = renderWithProviders(<TcpMiddlewaresPage />, {
route: '/tcp/middlewares',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('TCP Middlewares page')).toBeInTheDocument()
expect(getByTestId('/tcp/middlewares page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(2)
@ -58,6 +61,7 @@ describe('<TcpMiddlewaresPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/tcp/middlewares', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -14,7 +15,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
import { parseMiddlewareType } from 'libs/parsers'
export const makeRowRender = (): RenderRowType => {
@ -109,7 +109,10 @@ export const TcpMiddlewares = () => {
)
return (
<Page title="TCP Middlewares">
<>
<Helmet>
<title>TCP Middlewares - Traefik Proxy</title>
</Helmet>
<TableFilter />
<TcpMiddlewaresRender
error={error}
@ -120,6 +123,6 @@ export const TcpMiddlewares = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -7,6 +7,7 @@ describe('<TcpRouterPage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<TcpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
{ route: '/tcp/routers/mock-router', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<TcpRouterPage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<TcpRouterRender name="mock-router" data={undefined} error={undefined} />,
{ route: '/tcp/routers/mock-router', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<TcpRouterPage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<TcpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/tcp/routers/mock-router', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -66,6 +69,7 @@ describe('<TcpRouterPage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
{ route: '/tcp/routers/tcp-all@docker', withPage: true },
)
const routerStructure = getByTestId('router-structure')

View file

@ -1,4 +1,5 @@
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'
@ -6,7 +7,6 @@ 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 Page from 'layout/Page'
import { RouterStructure } from 'pages/http/HttpRouter'
import { NotFound } from 'pages/NotFound'
@ -37,17 +37,23 @@ type TcpRouterRenderProps = {
export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
<CardListSection bigDescription />
<CardListSection />
@ -57,7 +63,7 @@ export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) =>
<DetailSectionSkeleton />
<DetailSectionSkeleton />
</SpacedColumns>
</Page>
</>
)
}
@ -66,10 +72,13 @@ export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) =>
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<RouterStructure data={data} protocol="tcp" />
<RouterDetail data={data} />
</Page>
</>
)
}

View file

@ -39,10 +39,13 @@ describe('<TcpRoutersPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<TcpRoutersPage />)
const { container, getByTestId } = renderWithProviders(<TcpRoutersPage />, {
route: '/tcp/routers',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('TCP Routers page')).toBeInTheDocument()
expect(getByTestId('/tcp/routers page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3)
@ -76,6 +79,7 @@ describe('<TcpRoutersPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/tcp/routers', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
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'
@ -16,7 +17,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (): RenderRowType => {
const TcpRoutersRenderRow = (row) => (
@ -126,7 +126,10 @@ export const TcpRouters = () => {
)
return (
<Page title="TCP Routers">
<>
<Helmet>
<title>TCP Routers - Traefik Proxy</title>
</Helmet>
<TableFilter />
<TcpRoutersRender
error={error}
@ -137,6 +140,6 @@ export const TcpRouters = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -7,6 +7,7 @@ describe('<TcpServicePage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<TcpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<TcpServicePage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<TcpServiceRender name="mock-service" data={undefined} error={undefined} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<TcpServicePage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<TcpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -69,6 +72,7 @@ describe('<TcpServicePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -150,6 +154,7 @@ describe('<TcpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
const serversList = getByTestId('tcp-servers-list')
@ -176,6 +181,7 @@ describe('<TcpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/tcp/services/mock-service', withPage: true },
)
expect(() => {

View file

@ -1,5 +1,6 @@
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'
@ -16,7 +17,6 @@ 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 Page from 'layout/Page'
import { NotFound } from 'pages/NotFound'
type TcpDetailProps = {
@ -238,17 +238,23 @@ type TcpServiceRenderProps = {
export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
<SpacedColumns>
<DetailSectionSkeleton narrow />
@ -256,7 +262,7 @@ export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) =
<DetailSectionSkeleton narrow />
</SpacedColumns>
<UsedByRoutersSkeleton />
</Page>
</>
)
}
@ -265,11 +271,14 @@ export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) =
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<H1 css={{ mb: '$7' }}>{data.name}</H1>
<TcpServicePanels data={data} />
<UsedByRoutersSection data={data} protocol="tcp" />
</Page>
</>
)
}

View file

@ -36,10 +36,13 @@ describe('<TcpServicesPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<TcpServicesPage />)
const { container, getByTestId } = renderWithProviders(<TcpServicesPage />, {
route: '/tcp/services',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('TCP Services page')).toBeInTheDocument()
expect(getByTestId('/tcp/services page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3)
@ -73,6 +76,7 @@ describe('<TcpServicesPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/tcp/services', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -14,7 +15,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (): RenderRowType => {
const TcpServicesRenderRow = (row) => (
@ -108,7 +108,10 @@ export const TcpServices = () => {
)
return (
<Page title="TCP Services">
<>
<Helmet>
<title>TCP Services - Traefik Proxy</title>
</Helmet>
<TableFilter />
<TcpServicesRender
error={error}
@ -119,6 +122,6 @@ export const TcpServices = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -7,6 +7,7 @@ describe('<UdpRouterPage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<UdpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
{ route: '/udp/routers/mock-router', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<UdpRouterPage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<UdpRouterRender name="mock-router" data={undefined} error={undefined} />,
{ route: '/udp/routers/mock-router', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<UdpRouterPage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<UdpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/udp/routers/mock-router', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -51,6 +54,7 @@ describe('<UdpRouterPage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<UdpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
{ route: '/udp/routers/udp-all@docker', withPage: true },
)
const routerStructure = getByTestId('router-structure')

View file

@ -1,10 +1,10 @@
import { Flex, styled, Text } from '@traefiklabs/faency'
import { Helmet } from 'react-helmet-async'
import { useParams } from 'react-router-dom'
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
import RouterPanel from 'components/resources/RouterPanel'
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
import Page from 'layout/Page'
import { RouterStructure } from 'pages/http/HttpRouter'
import { NotFound } from 'pages/NotFound'
@ -33,17 +33,23 @@ type UdpRouterRenderProps = {
export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
<CardListSection bigDescription />
<CardListSection />
@ -53,7 +59,7 @@ export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) =>
<DetailSectionSkeleton />
<DetailSectionSkeleton />
</SpacedColumns>
</Page>
</>
)
}
@ -62,10 +68,13 @@ export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) =>
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<RouterStructure data={data} protocol="udp" />
<RouterDetail data={data} />
</Page>
</>
)
}

View file

@ -39,10 +39,13 @@ describe('<UdpRoutersPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<UdpRoutersPage />)
const { container, getByTestId } = renderWithProviders(<UdpRoutersPage />, {
route: '/udp/routers',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('UDP Routers page')).toBeInTheDocument()
expect(getByTestId('/udp/routers page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3)
@ -76,6 +79,7 @@ describe('<UdpRoutersPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/udp/routers', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -15,7 +16,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (): RenderRowType => {
const UdpRoutersRenderRow = (row) => (
@ -111,7 +111,10 @@ export const UdpRouters = () => {
)
return (
<Page title="UDP Routers">
<>
<Helmet>
<title>UDP Routers - Traefik Proxy</title>
</Helmet>
<TableFilter />
<UdpRoutersRender
error={error}
@ -122,6 +125,6 @@ export const UdpRouters = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -7,6 +7,7 @@ describe('<UdpServicePage />', () => {
it('should render the error message', () => {
const { getByTestId } = renderWithProviders(
<UdpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
{ route: '/udp/services/mock-service', withPage: true },
)
expect(getByTestId('error-text')).toBeInTheDocument()
})
@ -14,6 +15,7 @@ describe('<UdpServicePage />', () => {
it('should render the skeleton', () => {
const { getByTestId } = renderWithProviders(
<UdpServiceRender name="mock-service" data={undefined} error={undefined} />,
{ route: '/udp/services/mock-service', withPage: true },
)
expect(getByTestId('skeleton')).toBeInTheDocument()
})
@ -21,6 +23,7 @@ describe('<UdpServicePage />', () => {
it('should render the not found page', () => {
const { getByTestId } = renderWithProviders(
<UdpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
{ route: '/udp/services/mock-service', withPage: true },
)
expect(getByTestId('Not found page')).toBeInTheDocument()
})
@ -59,6 +62,7 @@ describe('<UdpServicePage />', () => {
const { container, getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/udp/services/mock-service', withPage: true },
)
const headings = Array.from(container.getElementsByTagName('h1'))
@ -128,6 +132,7 @@ describe('<UdpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/udp/services/mock-service', withPage: true },
)
const serversList = getByTestId('servers-list')
@ -154,6 +159,7 @@ describe('<UdpServicePage />', () => {
const { getByTestId } = renderWithProviders(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<UdpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
{ route: '/udp/services/mock-service', withPage: true },
)
expect(() => {

View file

@ -1,10 +1,10 @@
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 Page from 'layout/Page'
import { ServicePanels } from 'pages/http/HttpService'
import { NotFound } from 'pages/NotFound'
@ -23,24 +23,30 @@ type UdpServiceRenderProps = {
export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) => {
if (error) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Text data-testid="error-text">
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
</Text>
</Page>
</>
)
}
if (!data) {
return (
<Page title={name}>
<>
<Helmet>
<title>{name} - Traefik Proxy</title>
</Helmet>
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
<SpacedColumns>
<DetailSectionSkeleton narrow />
<DetailSectionSkeleton narrow />
</SpacedColumns>
<UsedByRoutersSkeleton />
</Page>
</>
)
}
@ -49,11 +55,14 @@ export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) =
}
return (
<Page title={name}>
<>
<Helmet>
<title>{data.name} - Traefik Proxy</title>
</Helmet>
<H1 css={{ mb: '$7' }}>{data.name}</H1>
<ServicePanels data={data} />
<UsedByRoutersSection data={data} protocol="udp" />
</Page>
</>
)
}

View file

@ -36,10 +36,13 @@ describe('<UdpServicesPage />', () => {
.spyOn(useFetchWithPagination, 'default')
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
const { container, getByTestId } = renderWithProviders(<UdpServicesPage />)
const { container, getByTestId } = renderWithProviders(<UdpServicesPage />, {
route: '/udp/services',
withPage: true,
})
expect(mock).toHaveBeenCalled()
expect(getByTestId('UDP Services page')).toBeInTheDocument()
expect(getByTestId('/udp/services page')).toBeInTheDocument()
const tbody = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[1]
expect(tbody.querySelectorAll('a[role="row"]')).toHaveLength(3)
@ -73,6 +76,7 @@ describe('<UdpServicesPage />', () => {
pageCount={1}
pages={[]}
/>,
{ route: '/udp/services', withPage: true },
)
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]

View file

@ -1,5 +1,6 @@
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, 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'
@ -14,7 +15,6 @@ import Tooltip from 'components/Tooltip'
import TooltipText from 'components/TooltipText'
import useFetchWithPagination, { pagesResponseInterface, RenderRowType } from 'hooks/use-fetch-with-pagination'
import { EmptyPlaceholder } from 'layout/EmptyPlaceholder'
import Page from 'layout/Page'
export const makeRowRender = (): RenderRowType => {
const UdpServicesRenderRow = (row) => (
@ -108,7 +108,10 @@ export const UdpServices = () => {
)
return (
<Page title="UDP Services">
<>
<Helmet>
<title>UDP Services - Traefik Proxy</title>
</Helmet>
<TableFilter />
<UdpServicesRender
error={error}
@ -119,6 +122,6 @@ export const UdpServices = () => {
pageCount={pageCount}
pages={pages}
/>
</Page>
</>
)
}

View file

@ -5,5 +5,6 @@ interface Window {
declare namespace JSX {
interface IntrinsicElements {
'hub-button-app': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>
'hub-ui-demo-app': { key: string; path: string; theme: 'dark' | 'light'; baseurl: string; containercss: string }
}
}

View file

@ -1,10 +1,11 @@
import { cleanup, render } from '@testing-library/react'
import { FaencyProvider } from '@traefiklabs/faency'
import { HelmetProvider } from 'react-helmet-async'
import { BrowserRouter } from 'react-router-dom'
import { MemoryRouter } from 'react-router-dom'
import { SWRConfig } from 'swr'
import { afterEach } from 'vitest'
import Page from '../layout/Page'
import fetch from '../libs/fetch'
afterEach(() => {
@ -25,7 +26,7 @@ export { default as userEvent } from '@testing-library/user-event'
// override render export
export { customRender as render } // eslint-disable-line import/export
export function renderWithProviders(ui: React.ReactElement) {
export function renderWithProviders(ui: React.ReactElement, { route = '/', withPage = false } = {}) {
return customRender(ui, {
wrapper: ({ children }) => (
<FaencyProvider>
@ -36,7 +37,7 @@ export function renderWithProviders(ui: React.ReactElement) {
fetcher: fetch,
}}
>
<BrowserRouter>{children}</BrowserRouter>
<MemoryRouter initialEntries={[route]}>{withPage ? <Page>{children}</Page> : children}</MemoryRouter>
</SWRConfig>
</HelmetProvider>
</FaencyProvider>

View file

@ -1,5 +1,6 @@
import '@testing-library/jest-dom'
import 'vitest-canvas-mock'
import '@vitest/web-worker'
import * as matchers from 'jest-extended'
import { expect } from 'vitest'
@ -12,6 +13,7 @@ export class IntersectionObserver {
root = null
rootMargin = ''
thresholds = []
scrollMargin = ''
disconnect() {
return null
@ -43,10 +45,10 @@ class ResizeObserver {
}
beforeAll(() => {
global.IntersectionObserver = IntersectionObserver
globalThis.IntersectionObserver = IntersectionObserver
window.IntersectionObserver = IntersectionObserver
global.ResizeObserver = ResizeObserver
globalThis.ResizeObserver = ResizeObserver
window.ResizeObserver = ResizeObserver
Object.defineProperty(window, 'matchMedia', {

File diff suppressed because it is too large Load diff