Add Traefik Hub demo in dashboard
This commit is contained in:
parent
10be359327
commit
db4f262916
64 changed files with 2481 additions and 622 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
BIN
webui/public/img/gopher-something-went-wrong.png
Normal file
BIN
webui/public/img/gopher-something-went-wrong.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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'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'm sorry, nothing around here...</Text>
|
||||
</Box>
|
||||
<Button variant="primary" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
204
webui/src/pages/hub-demo/HubDashboard.spec.tsx
Normal file
204
webui/src/pages/hub-demo/HubDashboard.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
147
webui/src/pages/hub-demo/HubDashboard.tsx
Normal file
147
webui/src/pages/hub-demo/HubDashboard.tsx
Normal 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
|
||||
84
webui/src/pages/hub-demo/HubDemoNav.tsx
Normal file
84
webui/src/pages/hub-demo/HubDemoNav.tsx
Normal 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
|
||||
15
webui/src/pages/hub-demo/demoNavContext.tsx
Normal file
15
webui/src/pages/hub-demo/demoNavContext.tsx
Normal 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
21
webui/src/pages/hub-demo/hub-demo.d.ts
vendored
Normal 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[]
|
||||
}
|
||||
}
|
||||
68
webui/src/pages/hub-demo/icons/api.tsx
Normal file
68
webui/src/pages/hub-demo/icons/api.tsx
Normal 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
|
||||
28
webui/src/pages/hub-demo/icons/dashboard.tsx
Normal file
28
webui/src/pages/hub-demo/icons/dashboard.tsx
Normal 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
|
||||
69
webui/src/pages/hub-demo/icons/gateway.tsx
Normal file
69
webui/src/pages/hub-demo/icons/gateway.tsx
Normal 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
|
||||
18
webui/src/pages/hub-demo/icons/hub.tsx
Normal file
18
webui/src/pages/hub-demo/icons/hub.tsx
Normal 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
|
||||
5
webui/src/pages/hub-demo/icons/index.ts
Normal file
5
webui/src/pages/hub-demo/icons/index.ts
Normal 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'
|
||||
48
webui/src/pages/hub-demo/icons/portal.tsx
Normal file
48
webui/src/pages/hub-demo/icons/portal.tsx
Normal 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
|
||||
301
webui/src/pages/hub-demo/use-hub-demo.spec.tsx
Normal file
301
webui/src/pages/hub-demo/use-hub-demo.spec.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
89
webui/src/pages/hub-demo/use-hub-demo.tsx
Normal file
89
webui/src/pages/hub-demo/use-hub-demo.tsx
Normal 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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
125
webui/src/pages/hub-demo/workers/scriptVerification.spec.ts
Normal file
125
webui/src/pages/hub-demo/workers/scriptVerification.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
57
webui/src/pages/hub-demo/workers/scriptVerification.ts
Normal file
57
webui/src/pages/hub-demo/workers/scriptVerification.ts
Normal 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
|
||||
189
webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts
Normal file
189
webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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'))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
1
webui/src/types/global.d.ts
vendored
1
webui/src/types/global.d.ts
vendored
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
636
webui/yarn.lock
636
webui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue