From db4f2629160af57f62870102dd05cf012d27185f Mon Sep 17 00:00:00 2001 From: "Gina A." <70909035+gndz07@users.noreply.github.com> Date: Mon, 27 Oct 2025 17:40:06 +0100 Subject: [PATCH] Add Traefik Hub demo in dashboard --- webui/package.json | 5 +- .../img/gopher-something-went-wrong.png | Bin 0 -> 46066 bytes webui/src/App.tsx | 88 +-- webui/src/components/SpinnerLoader.tsx | 6 +- .../components/icons/providers/Knative.tsx | 10 +- .../src/components/icons/providers/index.tsx | 2 +- webui/src/layout/Navigation.tsx | 14 +- webui/src/layout/Page.spec.tsx | 4 +- webui/src/layout/Page.tsx | 29 +- webui/src/pages/NotFound.tsx | 30 +- webui/src/pages/dashboard/Dashboard.tsx | 279 ++++---- webui/src/pages/http/HttpMiddleware.spec.tsx | 7 + webui/src/pages/http/HttpMiddleware.tsx | 23 +- webui/src/pages/http/HttpMiddlewares.spec.tsx | 8 +- webui/src/pages/http/HttpMiddlewares.tsx | 9 +- webui/src/pages/http/HttpRouter.spec.tsx | 4 + webui/src/pages/http/HttpRouter.tsx | 23 +- webui/src/pages/http/HttpRouters.spec.tsx | 8 +- webui/src/pages/http/HttpRouters.tsx | 9 +- webui/src/pages/http/HttpService.spec.tsx | 6 + webui/src/pages/http/HttpService.tsx | 23 +- webui/src/pages/http/HttpServices.spec.tsx | 8 +- webui/src/pages/http/HttpServices.tsx | 9 +- .../src/pages/hub-demo/HubDashboard.spec.tsx | 204 ++++++ webui/src/pages/hub-demo/HubDashboard.tsx | 147 ++++ webui/src/pages/hub-demo/HubDemoNav.tsx | 84 +++ webui/src/pages/hub-demo/demoNavContext.tsx | 15 + webui/src/pages/hub-demo/hub-demo.d.ts | 21 + webui/src/pages/hub-demo/icons/api.tsx | 68 ++ webui/src/pages/hub-demo/icons/dashboard.tsx | 28 + webui/src/pages/hub-demo/icons/gateway.tsx | 69 ++ webui/src/pages/hub-demo/icons/hub.tsx | 18 + webui/src/pages/hub-demo/icons/index.ts | 5 + webui/src/pages/hub-demo/icons/portal.tsx | 48 ++ .../src/pages/hub-demo/use-hub-demo.spec.tsx | 301 +++++++++ webui/src/pages/hub-demo/use-hub-demo.tsx | 89 +++ .../scriptVerification.integration.spec.ts | 156 +++++ .../workers/scriptVerification.spec.ts | 125 ++++ .../hub-demo/workers/scriptVerification.ts | 57 ++ .../workers/scriptVerificationWorker.ts | 189 ++++++ webui/src/pages/tcp/TcpMiddleware.spec.tsx | 5 + webui/src/pages/tcp/TcpMiddleware.tsx | 23 +- webui/src/pages/tcp/TcpMiddlewares.spec.tsx | 8 +- webui/src/pages/tcp/TcpMiddlewares.tsx | 9 +- webui/src/pages/tcp/TcpRouter.spec.tsx | 4 + webui/src/pages/tcp/TcpRouter.tsx | 23 +- webui/src/pages/tcp/TcpRouters.spec.tsx | 8 +- webui/src/pages/tcp/TcpRouters.tsx | 9 +- webui/src/pages/tcp/TcpService.spec.tsx | 6 + webui/src/pages/tcp/TcpService.tsx | 23 +- webui/src/pages/tcp/TcpServices.spec.tsx | 8 +- webui/src/pages/tcp/TcpServices.tsx | 9 +- webui/src/pages/udp/UdpRouter.spec.tsx | 4 + webui/src/pages/udp/UdpRouter.tsx | 23 +- webui/src/pages/udp/UdpRouters.spec.tsx | 8 +- webui/src/pages/udp/UdpRouters.tsx | 9 +- webui/src/pages/udp/UdpService.spec.tsx | 6 + webui/src/pages/udp/UdpService.tsx | 23 +- webui/src/pages/udp/UdpServices.spec.tsx | 8 +- webui/src/pages/udp/UdpServices.tsx | 9 +- webui/src/types/global.d.ts | 1 + webui/src/utils/test.tsx | 7 +- webui/test/setup.ts | 6 +- webui/yarn.lock | 636 +++++++++--------- 64 files changed, 2481 insertions(+), 622 deletions(-) create mode 100644 webui/public/img/gopher-something-went-wrong.png create mode 100644 webui/src/pages/hub-demo/HubDashboard.spec.tsx create mode 100644 webui/src/pages/hub-demo/HubDashboard.tsx create mode 100644 webui/src/pages/hub-demo/HubDemoNav.tsx create mode 100644 webui/src/pages/hub-demo/demoNavContext.tsx create mode 100644 webui/src/pages/hub-demo/hub-demo.d.ts create mode 100644 webui/src/pages/hub-demo/icons/api.tsx create mode 100644 webui/src/pages/hub-demo/icons/dashboard.tsx create mode 100644 webui/src/pages/hub-demo/icons/gateway.tsx create mode 100644 webui/src/pages/hub-demo/icons/hub.tsx create mode 100644 webui/src/pages/hub-demo/icons/index.ts create mode 100644 webui/src/pages/hub-demo/icons/portal.tsx create mode 100644 webui/src/pages/hub-demo/use-hub-demo.spec.tsx create mode 100644 webui/src/pages/hub-demo/use-hub-demo.tsx create mode 100644 webui/src/pages/hub-demo/workers/scriptVerification.integration.spec.ts create mode 100644 webui/src/pages/hub-demo/workers/scriptVerification.spec.ts create mode 100644 webui/src/pages/hub-demo/workers/scriptVerification.ts create mode 100644 webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts diff --git a/webui/package.json b/webui/package.json index 3721a8ae1..d2972a97c 100644 --- a/webui/package.json +++ b/webui/package.json @@ -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": { diff --git a/webui/public/img/gopher-something-went-wrong.png b/webui/public/img/gopher-something-went-wrong.png new file mode 100644 index 0000000000000000000000000000000000000000..fb0d6d4c31521832673ca3775184dae5104967ad GIT binary patch literal 46066 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 literal 0 HcmV?d00001 diff --git a/webui/src/App.tsx b/webui/src/App.tsx index b3f536ef2..8e256437f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -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 ( - }> - - }> - - - } - /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + }> + + }> + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + {/* Hub Dashboard demo content */} + {hubDemoRoutes?.map((route, idx) => )} + + } /> + + + ) } 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 = () => { > - {customGlobalStyle()} - - + + {customGlobalStyle()} + + + diff --git a/webui/src/components/SpinnerLoader.tsx b/webui/src/components/SpinnerLoader.tsx index 663130307..e336fc436 100644 --- a/webui/src/components/SpinnerLoader.tsx +++ b/webui/src/components/SpinnerLoader.tsx @@ -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 }) => ( - + ) diff --git a/webui/src/components/icons/providers/Knative.tsx b/webui/src/components/icons/providers/Knative.tsx index e6adf46e2..8eaf21d50 100644 --- a/webui/src/components/icons/providers/Knative.tsx +++ b/webui/src/components/icons/providers/Knative.tsx @@ -2,10 +2,12 @@ import { ProviderIconProps } from 'components/icons/providers' export default function Knative(props: ProviderIconProps) { return ( - - + + ) } diff --git a/webui/src/components/icons/providers/index.tsx b/webui/src/components/icons/providers/index.tsx index cd953d1b0..8745a7644 100644 --- a/webui/src/components/icons/providers/index.tsx +++ b/webui/src/components/icons/providers/index.tsx @@ -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' diff --git a/webui/src/layout/Navigation.tsx b/webui/src/layout/Navigation.tsx index 62b29eedf..658ef2257 100644 --- a/webui/src/layout/Navigation.tsx +++ b/webui/src/layout/Navigation.tsx @@ -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 = ({ ))} ))} - + } css={{ @@ -283,12 +285,14 @@ export const SideNav = ({ {!isSmallScreen || isExpanded ? 'Plugins' : ''} + + ) } -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 ( - - {hasHubButtonComponent && ( + + {!noHubButton && hasHubButtonComponent && ( ', () => { it('should render an empty page', () => { - const { getByTestId } = renderWithProviders() - expect(getByTestId('Test page')).toBeInTheDocument() + const { getByTestId } = renderWithProviders(, { route: '/test' }) + expect(getByTestId('/test page')).toBeInTheDocument() }) }) diff --git a/webui/src/layout/Page.tsx b/webui/src/layout/Page.tsx index f11afa091..ec8b6ea81 100644 --- a/webui/src/layout/Page.tsx +++ b/webui/src/layout/Page.tsx @@ -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 ( + + + {children} + + ) + }, [children, isDemoPage, location.pathname]) return ( {globalStyles()} - {title ? `${title} - ` : ''}Traefik Proxy + Traefik Proxy @@ -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 } }} > - - - {children} - + {renderedContent} diff --git a/webui/src/pages/NotFound.tsx b/webui/src/pages/NotFound.tsx index 51a130c4c..486b0b217 100644 --- a/webui/src/pages/NotFound.tsx +++ b/webui/src/pages/NotFound.tsx @@ -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 ( - - - -

404

- - - I'm sorry, nothing around here... - - - - + + + Not found - Traefik Proxy + + +

404

+
+ + I'm sorry, nothing around here... + + +
) } diff --git a/webui/src/pages/dashboard/Dashboard.tsx b/webui/src/pages/dashboard/Dashboard.tsx index 9100823eb..b26cf0a9c 100644 --- a/webui/src/pages/dashboard/Dashboard.tsx +++ b/webui/src/pages/dashboard/Dashboard.tsx @@ -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 ( - - - - {entrypoints?.map((i, idx) => ( - - {i.address} - - ))} - + + + Dashboard - Traefik Proxy + + + {entrypoints?.map((i, idx) => ( + + {i.address} + + ))} + - - {overview?.http && hasResources.http ? ( - RESOURCES.map((i) => ( - - )) - ) : ( - No related objects to show. - )} - + + {overview?.http && hasResources.http ? ( + RESOURCES.map((i) => ( + + )) + ) : ( + No related objects to show. + )} + - - {overview?.tcp && hasResources.tcp ? ( - RESOURCES.map((i) => ( - - )) - ) : ( - No related objects to show. - )} - + + {overview?.tcp && hasResources.tcp ? ( + RESOURCES.map((i) => ( + + )) + ) : ( + No related objects to show. + )} + - - {overview?.udp && hasResources.udp ? ( - RESOURCES.map((i) => ( - - )) - ) : ( - No related objects to show. - )} - + + {overview?.udp && hasResources.udp ? ( + RESOURCES.map((i) => ( + + )) + ) : ( + No related objects to show. + )} + - - {features.length - ? features.map((i, idx) => { - return - }) - : null} - + + {features.length + ? features.map((i, idx) => { + return + }) + : null} + - - {overview?.providers?.length ? ( - overview.providers.map((p, idx) => ( - - - - {p} - - - )) - ) : ( - No related objects to show. - )} - - - + + {overview?.providers?.length ? ( + overview.providers.map((p, idx) => ( + + + + {p} + + + )) + ) : ( + No related objects to show. + )} + + ) } export const DashboardSkeleton = () => { return ( - - - - {[...Array(5)].map((_, i) => ( - - ))} - + + + {[...Array(5)].map((_, i) => ( + + ))} + - - {[...Array(3)].map((_, i) => ( - - ))} - + + {[...Array(3)].map((_, i) => ( + + ))} + - - {[...Array(3)].map((_, i) => ( - - ))} - + + {[...Array(3)].map((_, i) => ( + + ))} + - - {[...Array(3)].map((_, i) => ( - - ))} - + + {[...Array(3)].map((_, i) => ( + + ))} + - - {[...Array(3)].map((_, i) => ( - - ))} - + + {[...Array(3)].map((_, i) => ( + + ))} + - - {[...Array(3)].map((_, i) => ( - - ))} - - - + + {[...Array(3)].map((_, i) => ( + + ))} + + ) } diff --git a/webui/src/pages/http/HttpMiddleware.spec.tsx b/webui/src/pages/http/HttpMiddleware.spec.tsx index d7a7c39bb..8bf003735 100644 --- a/webui/src/pages/http/HttpMiddleware.spec.tsx +++ b/webui/src/pages/http/HttpMiddleware.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -53,6 +56,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/middlewares/middleware-simple', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -99,6 +103,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/middlewares/middleware-plugin', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -338,6 +343,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/middlewares/middleware-complex', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -459,6 +465,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/middlewares/middleware-plugin-no-type', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) diff --git a/webui/src/pages/http/HttpMiddleware.tsx b/webui/src/pages/http/HttpMiddleware.tsx index 4b2fe7e2a..d762e6975 100644 --- a/webui/src/pages/http/HttpMiddleware.tsx +++ b/webui/src/pages/http/HttpMiddleware.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Middleware right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + - + ) } @@ -52,7 +58,10 @@ export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRender } return ( - + <> + + {data.name} - Traefik Proxy +

{data.name}

@@ -60,7 +69,7 @@ export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRender -
+ ) } diff --git a/webui/src/pages/http/HttpMiddlewares.spec.tsx b/webui/src/pages/http/HttpMiddlewares.spec.tsx index 1f4ce2607..b49b42062 100644 --- a/webui/src/pages/http/HttpMiddlewares.spec.tsx +++ b/webui/src/pages/http/HttpMiddlewares.spec.tsx @@ -76,10 +76,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/http/HttpMiddlewares.tsx b/webui/src/pages/http/HttpMiddlewares.tsx index f61a4ed6b..e3274009e 100644 --- a/webui/src/pages/http/HttpMiddlewares.tsx +++ b/webui/src/pages/http/HttpMiddlewares.tsx @@ -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 ( - + <> + + HTTP Middlewares - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/http/HttpRouter.spec.tsx b/webui/src/pages/http/HttpRouter.spec.tsx index a7f1e3ad7..ca90455d9 100644 --- a/webui/src/pages/http/HttpRouter.spec.tsx +++ b/webui/src/pages/http/HttpRouter.spec.tsx @@ -10,6 +10,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -17,6 +18,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -24,6 +26,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -40,6 +43,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/routers/orphan-router@file', withPage: true }, ) const routerStructure = getByTestId('router-structure') diff --git a/webui/src/pages/http/HttpRouter.tsx b/webui/src/pages/http/HttpRouter.tsx index dbb493e4d..62801083a 100644 --- a/webui/src/pages/http/HttpRouter.tsx +++ b/webui/src/pages/http/HttpRouter.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Router right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + @@ -127,7 +133,7 @@ export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) = - + ) } @@ -136,10 +142,13 @@ export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) = } return ( - + <> + + {data.name} - Traefik Proxy + - + ) } diff --git a/webui/src/pages/http/HttpRouters.spec.tsx b/webui/src/pages/http/HttpRouters.spec.tsx index bdddd21b4..a4df44257 100644 --- a/webui/src/pages/http/HttpRouters.spec.tsx +++ b/webui/src/pages/http/HttpRouters.spec.tsx @@ -49,10 +49,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/http/HttpRouters.tsx b/webui/src/pages/http/HttpRouters.tsx index d646b8f42..06c2c255f 100644 --- a/webui/src/pages/http/HttpRouters.tsx +++ b/webui/src/pages/http/HttpRouters.tsx @@ -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 ( - + <> + + HTTP Routers - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/http/HttpService.spec.tsx b/webui/src/pages/http/HttpService.spec.tsx index 781d43099..76059e21d 100644 --- a/webui/src/pages/http/HttpService.spec.tsx +++ b/webui/src/pages/http/HttpService.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/http/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -71,6 +74,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/services/mock-service', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -142,6 +146,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/services/mock-service', withPage: true }, ) const healthCheck = getByTestId('health-check') @@ -196,6 +201,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/http/services/mock-service', withPage: true }, ) const mirrorServices = getByTestId('mirror-services') diff --git a/webui/src/pages/http/HttpService.tsx b/webui/src/pages/http/HttpService.tsx index 4e74c552f..1761cbf05 100644 --- a/webui/src/pages/http/HttpService.tsx +++ b/webui/src/pages/http/HttpService.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Service right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + @@ -288,7 +294,7 @@ export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) - + ) } @@ -297,11 +303,14 @@ export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) } return ( - + <> + + {data.name} - Traefik Proxy +

{data.name}

-
+ ) } diff --git a/webui/src/pages/http/HttpServices.spec.tsx b/webui/src/pages/http/HttpServices.spec.tsx index 720fc2549..31a749c8c 100644 --- a/webui/src/pages/http/HttpServices.spec.tsx +++ b/webui/src/pages/http/HttpServices.spec.tsx @@ -49,10 +49,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/http/HttpServices.tsx b/webui/src/pages/http/HttpServices.tsx index 6febd6b1f..8c41badcd 100644 --- a/webui/src/pages/http/HttpServices.tsx +++ b/webui/src/pages/http/HttpServices.tsx @@ -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 ( - + <> + + HTTP Services - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/hub-demo/HubDashboard.spec.tsx b/webui/src/pages/hub-demo/HubDashboard.spec.tsx new file mode 100644 index 000000000..7f338ab69 --- /dev/null +++ b/webui/src/pages/hub-demo/HubDashboard.spec.tsx @@ -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 + + 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(, { + 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(, { + 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() + + 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() + + 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() + + 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() + + 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() + + 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( + , + ) + + 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() + + await waitFor(() => { + const secondHubComponent = secondContainer.querySelector('hub-ui-demo-app') + expect(secondHubComponent).toBeInTheDocument() + }) + + expect(mockVerifyScriptSignature).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/webui/src/pages/hub-demo/HubDashboard.tsx b/webui/src/pages/hub-demo/HubDashboard.tsx new file mode 100644 index 000000000..fdbb4d37f --- /dev/null +++ b/webui/src/pages/hub-demo/HubDashboard.tsx @@ -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(undefined) + const [signatureVerified, setSignatureVerified] = useState(false) + const [verificationInProgress, setVerificationInProgress] = useState(false) + const [scriptBlobUrl, setScriptBlobUrl] = useState(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 ( + + + Oops! We couldn't load the demo content. + + Don't worry — you can still learn more about{' '} + + Traefik Hub API Management + {' '} + on our{' '} + + website + {' '} + or in our{' '} + + documentation + + . + + + ) + } + + return ( + + + Hub Demo - Traefik Proxy + + {signatureVerified && scriptBlobUrl && } + + + + + {verificationInProgress ? ( + + + + ) : ( + + )} + + ) +} + +export default HubDashboard diff --git a/webui/src/pages/hub-demo/HubDemoNav.tsx b/webui/src/pages/hub-demo/HubDemoNav.tsx new file mode 100644 index 000000000..eba41dc07 --- /dev/null +++ b/webui/src/pages/hub-demo/HubDemoNav.tsx @@ -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 ( + + setIsCollapsed(!isCollapsed)} + > + + {isSmallScreen ? ( + + + + + + ) : ( + <> + + API management + + + Demo + + + )} + + + + {hubDemoNavItems.map((route, idx) => ( + + ))} + + + ) +} + +export default ApimDemoNavMenu diff --git a/webui/src/pages/hub-demo/demoNavContext.tsx b/webui/src/pages/hub-demo/demoNavContext.tsx new file mode 100644 index 000000000..3f28afc78 --- /dev/null +++ b/webui/src/pages/hub-demo/demoNavContext.tsx @@ -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 {children} +} diff --git a/webui/src/pages/hub-demo/hub-demo.d.ts b/webui/src/pages/hub-demo/hub-demo.d.ts new file mode 100644 index 000000000..31c6cfde7 --- /dev/null +++ b/webui/src/pages/hub-demo/hub-demo.d.ts @@ -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[] + } +} diff --git a/webui/src/pages/hub-demo/icons/api.tsx b/webui/src/pages/hub-demo/icons/api.tsx new file mode 100644 index 000000000..04b51da6f --- /dev/null +++ b/webui/src/pages/hub-demo/icons/api.tsx @@ -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 ( + + + apis + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default ApiIcon diff --git a/webui/src/pages/hub-demo/icons/dashboard.tsx b/webui/src/pages/hub-demo/icons/dashboard.tsx new file mode 100644 index 000000000..44ab47aa6 --- /dev/null +++ b/webui/src/pages/hub-demo/icons/dashboard.tsx @@ -0,0 +1,28 @@ +import { Flex } from '@traefiklabs/faency' + +import { CustomIconProps } from 'components/icons' + +const DashboardIcon = ({ color = 'currentColor', css = {}, ...props }: CustomIconProps) => { + return ( + + + dashboard + + + + + + + + ) +} + +export default DashboardIcon diff --git a/webui/src/pages/hub-demo/icons/gateway.tsx b/webui/src/pages/hub-demo/icons/gateway.tsx new file mode 100644 index 000000000..d1f682bf4 --- /dev/null +++ b/webui/src/pages/hub-demo/icons/gateway.tsx @@ -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 ( + + + gateways_icon + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default GatewayIcon diff --git a/webui/src/pages/hub-demo/icons/hub.tsx b/webui/src/pages/hub-demo/icons/hub.tsx new file mode 100644 index 000000000..64050576f --- /dev/null +++ b/webui/src/pages/hub-demo/icons/hub.tsx @@ -0,0 +1,18 @@ +import { CustomIconProps } from 'components/icons' + +const Hub = (props: CustomIconProps) => { + const { color = 'currentColor', ...restProps } = props + + return ( + + + + + + ) +} + +export default Hub diff --git a/webui/src/pages/hub-demo/icons/index.ts b/webui/src/pages/hub-demo/icons/index.ts new file mode 100644 index 000000000..5118c0fe4 --- /dev/null +++ b/webui/src/pages/hub-demo/icons/index.ts @@ -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' diff --git a/webui/src/pages/hub-demo/icons/portal.tsx b/webui/src/pages/hub-demo/icons/portal.tsx new file mode 100644 index 000000000..a21413ce8 --- /dev/null +++ b/webui/src/pages/hub-demo/icons/portal.tsx @@ -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 ( + + + portals + + + + + + + + + + + + + + ) +} + +export default PortalIcon diff --git a/webui/src/pages/hub-demo/use-hub-demo.spec.tsx b/webui/src/pages/hub-demo/use-hub-demo.spec.tsx new file mode 100644 index 000000000..dba13cc97 --- /dev/null +++ b/webui/src/pages/hub-demo/use-hub-demo.spec.tsx @@ -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) + }) + }) +}) diff --git a/webui/src/pages/hub-demo/use-hub-demo.tsx b/webui/src/pages/hub-demo/use-hub-demo.tsx new file mode 100644 index 000000000..4eee4f455 --- /dev/null +++ b/webui/src/pages/hub-demo/use-hub-demo.tsx @@ -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 = { + dashboard: , + gateway: , + api: , + portal: , +} + +const useHubDemoRoutesManifest = (): HubDemo.Manifest | null => { + const [manifest, setManifest] = useState(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: , + }) + + if (route.dynamicSegments) { + route.dynamicSegments.forEach((segment) => { + routeObjects.push({ + path: `${basePath}${route.path}/${segment}`, + element: , + }) + }) + } + }) + + 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 } +} diff --git a/webui/src/pages/hub-demo/workers/scriptVerification.integration.spec.ts b/webui/src/pages/hub-demo/workers/scriptVerification.integration.spec.ts new file mode 100644 index 000000000..189632c64 --- /dev/null +++ b/webui/src/pages/hub-demo/workers/scriptVerification.integration.spec.ts @@ -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 + + 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) +}) diff --git a/webui/src/pages/hub-demo/workers/scriptVerification.spec.ts b/webui/src/pages/hub-demo/workers/scriptVerification.spec.ts new file mode 100644 index 000000000..1f438aa71 --- /dev/null +++ b/webui/src/pages/hub-demo/workers/scriptVerification.spec.ts @@ -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() + }) +}) diff --git a/webui/src/pages/hub-demo/workers/scriptVerification.ts b/webui/src/pages/hub-demo/workers/scriptVerification.ts new file mode 100644 index 000000000..e03a438da --- /dev/null +++ b/webui/src/pages/hub-demo/workers/scriptVerification.ts @@ -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 { + 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 diff --git a/webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts b/webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts new file mode 100644 index 000000000..902dce094 --- /dev/null +++ b/webui/src/pages/hub-demo/workers/scriptVerificationWorker.ts @@ -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 { + 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 { + 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 { + 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, + }) +} diff --git a/webui/src/pages/tcp/TcpMiddleware.spec.tsx b/webui/src/pages/tcp/TcpMiddleware.spec.tsx index b783e0958..73e69e01c 100644 --- a/webui/src/pages/tcp/TcpMiddleware.spec.tsx +++ b/webui/src/pages/tcp/TcpMiddleware.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/middlewares/mock-middleware', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -53,6 +56,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/middlewares/middleware-simple', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -103,6 +107,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/middlewares/middleware-complex', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) diff --git a/webui/src/pages/tcp/TcpMiddleware.tsx b/webui/src/pages/tcp/TcpMiddleware.tsx index f3637a46d..d85cf22ec 100644 --- a/webui/src/pages/tcp/TcpMiddleware.tsx +++ b/webui/src/pages/tcp/TcpMiddleware.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Middleware right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + - + ) } @@ -52,7 +58,10 @@ export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderPr } return ( - + <> + + {data.name} - Traefik Proxy +

{data.name}

@@ -60,7 +69,7 @@ export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderPr -
+ ) } diff --git a/webui/src/pages/tcp/TcpMiddlewares.spec.tsx b/webui/src/pages/tcp/TcpMiddlewares.spec.tsx index 3da9cfc5b..12e95f92f 100644 --- a/webui/src/pages/tcp/TcpMiddlewares.spec.tsx +++ b/webui/src/pages/tcp/TcpMiddlewares.spec.tsx @@ -29,10 +29,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/tcp/TcpMiddlewares.tsx b/webui/src/pages/tcp/TcpMiddlewares.tsx index c736d4c47..b0189a2e5 100644 --- a/webui/src/pages/tcp/TcpMiddlewares.tsx +++ b/webui/src/pages/tcp/TcpMiddlewares.tsx @@ -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 ( - + <> + + TCP Middlewares - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/tcp/TcpRouter.spec.tsx b/webui/src/pages/tcp/TcpRouter.spec.tsx index bd5fe7059..c1f5bb6a4 100644 --- a/webui/src/pages/tcp/TcpRouter.spec.tsx +++ b/webui/src/pages/tcp/TcpRouter.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -66,6 +69,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/routers/tcp-all@docker', withPage: true }, ) const routerStructure = getByTestId('router-structure') diff --git a/webui/src/pages/tcp/TcpRouter.tsx b/webui/src/pages/tcp/TcpRouter.tsx index 1bdac707c..92f9b47b2 100644 --- a/webui/src/pages/tcp/TcpRouter.tsx +++ b/webui/src/pages/tcp/TcpRouter.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Router right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + @@ -57,7 +63,7 @@ export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => - + ) } @@ -66,10 +72,13 @@ export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => } return ( - + <> + + {data.name} - Traefik Proxy + - + ) } diff --git a/webui/src/pages/tcp/TcpRouters.spec.tsx b/webui/src/pages/tcp/TcpRouters.spec.tsx index 60acab68e..fac801fb6 100644 --- a/webui/src/pages/tcp/TcpRouters.spec.tsx +++ b/webui/src/pages/tcp/TcpRouters.spec.tsx @@ -39,10 +39,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/tcp/TcpRouters.tsx b/webui/src/pages/tcp/TcpRouters.tsx index de8319e79..f3cd3d497 100644 --- a/webui/src/pages/tcp/TcpRouters.tsx +++ b/webui/src/pages/tcp/TcpRouters.tsx @@ -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 ( - + <> + + TCP Routers - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/tcp/TcpService.spec.tsx b/webui/src/pages/tcp/TcpService.spec.tsx index e6b835832..faa8ac0f5 100644 --- a/webui/src/pages/tcp/TcpService.spec.tsx +++ b/webui/src/pages/tcp/TcpService.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/tcp/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -69,6 +72,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/services/mock-service', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -150,6 +154,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/services/mock-service', withPage: true }, ) const serversList = getByTestId('tcp-servers-list') @@ -176,6 +181,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/tcp/services/mock-service', withPage: true }, ) expect(() => { diff --git a/webui/src/pages/tcp/TcpService.tsx b/webui/src/pages/tcp/TcpService.tsx index f2f3ef1fe..c36106156 100644 --- a/webui/src/pages/tcp/TcpService.tsx +++ b/webui/src/pages/tcp/TcpService.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Service right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + @@ -256,7 +262,7 @@ export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) = - + ) } @@ -265,11 +271,14 @@ export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) = } return ( - + <> + + {data.name} - Traefik Proxy +

{data.name}

-
+ ) } diff --git a/webui/src/pages/tcp/TcpServices.spec.tsx b/webui/src/pages/tcp/TcpServices.spec.tsx index cd838bee4..336a26a54 100644 --- a/webui/src/pages/tcp/TcpServices.spec.tsx +++ b/webui/src/pages/tcp/TcpServices.spec.tsx @@ -36,10 +36,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/tcp/TcpServices.tsx b/webui/src/pages/tcp/TcpServices.tsx index a56027b61..77480fd4e 100644 --- a/webui/src/pages/tcp/TcpServices.tsx +++ b/webui/src/pages/tcp/TcpServices.tsx @@ -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 ( - + <> + + TCP Services - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/udp/UdpRouter.spec.tsx b/webui/src/pages/udp/UdpRouter.spec.tsx index 65aa5567d..2404b70a3 100644 --- a/webui/src/pages/udp/UdpRouter.spec.tsx +++ b/webui/src/pages/udp/UdpRouter.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/routers/mock-router', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -51,6 +54,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/udp/routers/udp-all@docker', withPage: true }, ) const routerStructure = getByTestId('router-structure') diff --git a/webui/src/pages/udp/UdpRouter.tsx b/webui/src/pages/udp/UdpRouter.tsx index d1aebf1e8..a41bdb0df 100644 --- a/webui/src/pages/udp/UdpRouter.tsx +++ b/webui/src/pages/udp/UdpRouter.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Router right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + @@ -53,7 +59,7 @@ export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => - + ) } @@ -62,10 +68,13 @@ export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => } return ( - + <> + + {data.name} - Traefik Proxy + - + ) } diff --git a/webui/src/pages/udp/UdpRouters.spec.tsx b/webui/src/pages/udp/UdpRouters.spec.tsx index 8045cf8b7..658b8843b 100644 --- a/webui/src/pages/udp/UdpRouters.spec.tsx +++ b/webui/src/pages/udp/UdpRouters.spec.tsx @@ -39,10 +39,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/udp/UdpRouters.tsx b/webui/src/pages/udp/UdpRouters.tsx index 56c6d8414..ce6348b75 100644 --- a/webui/src/pages/udp/UdpRouters.tsx +++ b/webui/src/pages/udp/UdpRouters.tsx @@ -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 ( - + <> + + UDP Routers - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/pages/udp/UdpService.spec.tsx b/webui/src/pages/udp/UdpService.spec.tsx index 71114f560..b6150c8ee 100644 --- a/webui/src/pages/udp/UdpService.spec.tsx +++ b/webui/src/pages/udp/UdpService.spec.tsx @@ -7,6 +7,7 @@ describe('', () => { it('should render the error message', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('error-text')).toBeInTheDocument() }) @@ -14,6 +15,7 @@ describe('', () => { it('should render the skeleton', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('skeleton')).toBeInTheDocument() }) @@ -21,6 +23,7 @@ describe('', () => { it('should render the not found page', () => { const { getByTestId } = renderWithProviders( , + { route: '/udp/services/mock-service', withPage: true }, ) expect(getByTestId('Not found page')).toBeInTheDocument() }) @@ -59,6 +62,7 @@ describe('', () => { const { container, getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/udp/services/mock-service', withPage: true }, ) const headings = Array.from(container.getElementsByTagName('h1')) @@ -128,6 +132,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/udp/services/mock-service', withPage: true }, ) const serversList = getByTestId('servers-list') @@ -154,6 +159,7 @@ describe('', () => { const { getByTestId } = renderWithProviders( // eslint-disable-next-line @typescript-eslint/no-explicit-any , + { route: '/udp/services/mock-service', withPage: true }, ) expect(() => { diff --git a/webui/src/pages/udp/UdpService.tsx b/webui/src/pages/udp/UdpService.tsx index 9727a1663..132e63197 100644 --- a/webui/src/pages/udp/UdpService.tsx +++ b/webui/src/pages/udp/UdpService.tsx @@ -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 ( - + <> + + {name} - Traefik Proxy + Sorry, we could not fetch detail information for this Service right now. Please, try again later. - + ) } if (!data) { return ( - + <> + + {name} - Traefik Proxy + - + ) } @@ -49,11 +55,14 @@ export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) = } return ( - + <> + + {data.name} - Traefik Proxy +

{data.name}

-
+ ) } diff --git a/webui/src/pages/udp/UdpServices.spec.tsx b/webui/src/pages/udp/UdpServices.spec.tsx index 8bb51ef0d..e28d1dba3 100644 --- a/webui/src/pages/udp/UdpServices.spec.tsx +++ b/webui/src/pages/udp/UdpServices.spec.tsx @@ -36,10 +36,13 @@ describe('', () => { .spyOn(useFetchWithPagination, 'default') .mockImplementation(() => useFetchWithPaginationMock({ pages })) - const { container, getByTestId } = renderWithProviders() + const { container, getByTestId } = renderWithProviders(, { + 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('', () => { 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] diff --git a/webui/src/pages/udp/UdpServices.tsx b/webui/src/pages/udp/UdpServices.tsx index b02c2fdcf..5d6b47c66 100644 --- a/webui/src/pages/udp/UdpServices.tsx +++ b/webui/src/pages/udp/UdpServices.tsx @@ -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 ( - + <> + + UDP Services - Traefik Proxy + { pageCount={pageCount} pages={pages} /> - + ) } diff --git a/webui/src/types/global.d.ts b/webui/src/types/global.d.ts index e1503ed42..4b024488c 100644 --- a/webui/src/types/global.d.ts +++ b/webui/src/types/global.d.ts @@ -5,5 +5,6 @@ interface Window { declare namespace JSX { interface IntrinsicElements { 'hub-button-app': React.DetailedHTMLProps, HTMLElement> + 'hub-ui-demo-app': { key: string; path: string; theme: 'dark' | 'light'; baseurl: string; containercss: string } } } diff --git a/webui/src/utils/test.tsx b/webui/src/utils/test.tsx index 2c79760cf..995908e9f 100644 --- a/webui/src/utils/test.tsx +++ b/webui/src/utils/test.tsx @@ -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 }) => ( @@ -36,7 +37,7 @@ export function renderWithProviders(ui: React.ReactElement) { fetcher: fetch, }} > - {children} + {withPage ? {children} : children} diff --git a/webui/test/setup.ts b/webui/test/setup.ts index 7c53d16b8..fe734b9b7 100644 --- a/webui/test/setup.ts +++ b/webui/test/setup.ts @@ -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', { diff --git a/webui/yarn.lock b/webui/yarn.lock index 3934fc003..741958b91 100644 --- a/webui/yarn.lock +++ b/webui/yarn.lock @@ -2485,6 +2485,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + "@jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25": version: 0.3.25 resolution: "@jridgewell/trace-mapping@npm:0.3.25" @@ -2561,6 +2568,20 @@ __metadata: languageName: node linkType: hard +"@noble/ed25519@npm:^3.0.0": + version: 3.0.0 + resolution: "@noble/ed25519@npm:3.0.0" + checksum: 10c0/6355aed04523d063d3e9a9952926af4a0eaa722e08133f558d44963a3048bf6dae02cae9032d42238d1fcb2a93ef27658f2baf4cf07939457717a4337a89dc26 + languageName: node + linkType: hard + +"@noble/hashes@npm:^2.0.1": + version: 2.0.1 + resolution: "@noble/hashes@npm:2.0.1" + checksum: 10c0/e81769ce21c3b1c80141a3b99bd001f17edea09879aa936692ae39525477386d696101cd573928a304806efb2b9fa751e1dd83241c67d0c84d30091e85c79bdb + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -4113,9 +4134,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm-eabi@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-android-arm-eabi@npm:4.46.1" +"@rollup/rollup-android-arm-eabi@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-android-arm-eabi@npm:4.52.5" conditions: os=android & cpu=arm languageName: node linkType: hard @@ -4127,9 +4148,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-android-arm64@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-android-arm64@npm:4.46.1" +"@rollup/rollup-android-arm64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-android-arm64@npm:4.52.5" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -4141,9 +4162,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-arm64@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-darwin-arm64@npm:4.46.1" +"@rollup/rollup-darwin-arm64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-darwin-arm64@npm:4.52.5" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -4155,9 +4176,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-darwin-x64@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-darwin-x64@npm:4.46.1" +"@rollup/rollup-darwin-x64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-darwin-x64@npm:4.52.5" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -4169,9 +4190,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-arm64@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-freebsd-arm64@npm:4.46.1" +"@rollup/rollup-freebsd-arm64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-freebsd-arm64@npm:4.52.5" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -4183,9 +4204,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-freebsd-x64@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-freebsd-x64@npm:4.46.1" +"@rollup/rollup-freebsd-x64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-freebsd-x64@npm:4.52.5" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -4197,9 +4218,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-gnueabihf@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.46.1" +"@rollup/rollup-linux-arm-gnueabihf@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-arm-gnueabihf@npm:4.52.5" conditions: os=linux & cpu=arm & libc=glibc languageName: node linkType: hard @@ -4211,9 +4232,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm-musleabihf@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.46.1" +"@rollup/rollup-linux-arm-musleabihf@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-arm-musleabihf@npm:4.52.5" conditions: os=linux & cpu=arm & libc=musl languageName: node linkType: hard @@ -4225,9 +4246,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.46.1" +"@rollup/rollup-linux-arm64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-arm64-gnu@npm:4.52.5" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -4239,13 +4260,20 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-arm64-musl@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-arm64-musl@npm:4.46.1" +"@rollup/rollup-linux-arm64-musl@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-arm64-musl@npm:4.52.5" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard +"@rollup/rollup-linux-loong64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-loong64-gnu@npm:4.52.5" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + "@rollup/rollup-linux-loongarch64-gnu@npm:4.39.0": version: 4.39.0 resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.39.0" @@ -4253,13 +4281,6 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-loongarch64-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-loongarch64-gnu@npm:4.46.1" - conditions: os=linux & cpu=loong64 & libc=glibc - languageName: node - linkType: hard - "@rollup/rollup-linux-powerpc64le-gnu@npm:4.39.0": version: 4.39.0 resolution: "@rollup/rollup-linux-powerpc64le-gnu@npm:4.39.0" @@ -4267,9 +4288,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-ppc64-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.46.1" +"@rollup/rollup-linux-ppc64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-ppc64-gnu@npm:4.52.5" conditions: os=linux & cpu=ppc64 & libc=glibc languageName: node linkType: hard @@ -4281,9 +4302,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.46.1" +"@rollup/rollup-linux-riscv64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-riscv64-gnu@npm:4.52.5" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard @@ -4295,9 +4316,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-riscv64-musl@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.46.1" +"@rollup/rollup-linux-riscv64-musl@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-riscv64-musl@npm:4.52.5" conditions: os=linux & cpu=riscv64 & libc=musl languageName: node linkType: hard @@ -4309,9 +4330,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-s390x-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.46.1" +"@rollup/rollup-linux-s390x-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-s390x-gnu@npm:4.52.5" conditions: os=linux & cpu=s390x & libc=glibc languageName: node linkType: hard @@ -4323,9 +4344,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-gnu@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-x64-gnu@npm:4.46.1" +"@rollup/rollup-linux-x64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-x64-gnu@npm:4.52.5" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -4337,13 +4358,20 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-linux-x64-musl@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-linux-x64-musl@npm:4.46.1" +"@rollup/rollup-linux-x64-musl@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-linux-x64-musl@npm:4.52.5" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard +"@rollup/rollup-openharmony-arm64@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-openharmony-arm64@npm:4.52.5" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@rollup/rollup-win32-arm64-msvc@npm:4.39.0": version: 4.39.0 resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.39.0" @@ -4351,9 +4379,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-arm64-msvc@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.46.1" +"@rollup/rollup-win32-arm64-msvc@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-win32-arm64-msvc@npm:4.52.5" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -4365,13 +4393,20 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-ia32-msvc@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.46.1" +"@rollup/rollup-win32-ia32-msvc@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-win32-ia32-msvc@npm:4.52.5" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard +"@rollup/rollup-win32-x64-gnu@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-win32-x64-gnu@npm:4.52.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@rollup/rollup-win32-x64-msvc@npm:4.39.0": version: 4.39.0 resolution: "@rollup/rollup-win32-x64-msvc@npm:4.39.0" @@ -4379,9 +4414,9 @@ __metadata: languageName: node linkType: hard -"@rollup/rollup-win32-x64-msvc@npm:4.46.1": - version: 4.46.1 - resolution: "@rollup/rollup-win32-x64-msvc@npm:4.46.1" +"@rollup/rollup-win32-x64-msvc@npm:4.52.5": + version: 4.52.5 + resolution: "@rollup/rollup-win32-x64-msvc@npm:4.52.5" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4522,6 +4557,13 @@ __metadata: languageName: node linkType: hard +"@standard-schema/spec@npm:^1.0.0": + version: 1.0.0 + resolution: "@standard-schema/spec@npm:1.0.0" + checksum: 10c0/a1ab9a8bdc09b5b47aa8365d0e0ec40cc2df6437be02853696a0e377321653b0d3ac6f079a8c67d5ddbe9821025584b1fb71d9cc041a6666a96f1fadf2ece15f + languageName: node + linkType: hard + "@stitches/react@npm:1.2.7": version: 1.2.7 resolution: "@stitches/react@npm:1.2.7" @@ -5838,86 +5880,94 @@ __metadata: languageName: node linkType: hard -"@vitest/expect@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/expect@npm:3.2.4" +"@vitest/expect@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/expect@npm:4.0.3" dependencies: + "@standard-schema/spec": "npm:^1.0.0" "@types/chai": "npm:^5.2.2" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/7586104e3fd31dbe1e6ecaafb9a70131e4197dce2940f727b6a84131eee3decac7b10f9c7c72fa5edbdb68b6f854353bd4c0fa84779e274207fb7379563b10db + "@vitest/spy": "npm:4.0.3" + "@vitest/utils": "npm:4.0.3" + chai: "npm:^6.0.1" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/36a9ff769387f4475fea273ec3ee39553ed7607bda620eea3a3632cd88a1e8c97173abcce7bc4440c84bf55b8e752edacb519a64f02d7ad5b47df7770cb56883 languageName: node linkType: hard -"@vitest/mocker@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/mocker@npm:3.2.4" +"@vitest/mocker@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/mocker@npm:4.0.3" dependencies: - "@vitest/spy": "npm:3.2.4" + "@vitest/spy": "npm:4.0.3" estree-walker: "npm:^3.0.3" - magic-string: "npm:^0.30.17" + magic-string: "npm:^0.30.19" peerDependencies: msw: ^2.4.9 - vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0-0 peerDependenciesMeta: msw: optional: true vite: optional: true - checksum: 10c0/f7a4aea19bbbf8f15905847ee9143b6298b2c110f8b64789224cb0ffdc2e96f9802876aa2ca83f1ec1b6e1ff45e822abb34f0054c24d57b29ab18add06536ccd + checksum: 10c0/16c9ef064a55b0fd3e0d7adaddfeef5c41d569d8181c40d8fed75464b43445305b83c017b50c5ea88f49355bef64e32f75cfd35db0682e0a8f5bbcc3acbde264 languageName: node linkType: hard -"@vitest/pretty-format@npm:3.2.4, @vitest/pretty-format@npm:^3.2.4": - version: 3.2.4 - resolution: "@vitest/pretty-format@npm:3.2.4" +"@vitest/pretty-format@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/pretty-format@npm:4.0.3" dependencies: - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/5ad7d4278e067390d7d633e307fee8103958806a419ca380aec0e33fae71b44a64415f7a9b4bc11635d3c13d4a9186111c581d3cef9c65cc317e68f077456887 + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/031080fbcb16b42c511ef7553a0cdabcb78d0bf9a2bb960a03403ff4553ce05d15e13af4abe7f22bd187f369d3bfa1c59714a0b4d9db33313d7583346511f236 languageName: node linkType: hard -"@vitest/runner@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/runner@npm:3.2.4" +"@vitest/runner@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/runner@npm:4.0.3" dependencies: - "@vitest/utils": "npm:3.2.4" + "@vitest/utils": "npm:4.0.3" pathe: "npm:^2.0.3" - strip-literal: "npm:^3.0.0" - checksum: 10c0/e8be51666c72b3668ae3ea348b0196656a4a5adb836cb5e270720885d9517421815b0d6c98bfdf1795ed02b994b7bfb2b21566ee356a40021f5bf4f6ed4e418a + checksum: 10c0/77a4c76890d652115baded6004beb13f5f84e98d0a5175c38b3908f03875d6c02df998430e43b21bf324de9e1499069a54cf773854447607602cab5d9fe8cd60 languageName: node linkType: hard -"@vitest/snapshot@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/snapshot@npm:3.2.4" +"@vitest/snapshot@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/snapshot@npm:4.0.3" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - magic-string: "npm:^0.30.17" + "@vitest/pretty-format": "npm:4.0.3" + magic-string: "npm:^0.30.19" pathe: "npm:^2.0.3" - checksum: 10c0/f8301a3d7d1559fd3d59ed51176dd52e1ed5c2d23aa6d8d6aa18787ef46e295056bc726a021698d8454c16ed825ecba163362f42fa90258bb4a98cfd2c9424fc + checksum: 10c0/69e6a3ebdf3852cb949237989aa88c73ead9e6a1debd2227d130e0ec3c33a0bc07ab2d16538a247b8eea0c105f0c9f62419700bbc9230edc99d1df8083bf7b0a languageName: node linkType: hard -"@vitest/spy@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/spy@npm:3.2.4" - dependencies: - tinyspy: "npm:^4.0.3" - checksum: 10c0/6ebf0b4697dc238476d6b6a60c76ba9eb1dd8167a307e30f08f64149612fd50227682b876420e4c2e09a76334e73f72e3ebf0e350714dc22474258292e202024 +"@vitest/spy@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/spy@npm:4.0.3" + checksum: 10c0/185fc2621c44c974fe2d89523cc8e20db46b4213fb7f153b63c4091c744b14e1e2c79f6160a3db0aab3e91775c6974214499c643032a02c2261cc68692b4cad8 languageName: node linkType: hard -"@vitest/utils@npm:3.2.4": - version: 3.2.4 - resolution: "@vitest/utils@npm:3.2.4" +"@vitest/utils@npm:4.0.3": + version: 4.0.3 + resolution: "@vitest/utils@npm:4.0.3" dependencies: - "@vitest/pretty-format": "npm:3.2.4" - loupe: "npm:^3.1.4" - tinyrainbow: "npm:^2.0.0" - checksum: 10c0/024a9b8c8bcc12cf40183c246c244b52ecff861c6deb3477cbf487ac8781ad44c68a9c5fd69f8c1361878e55b97c10d99d511f2597f1f7244b5e5101d028ba64 + "@vitest/pretty-format": "npm:4.0.3" + tinyrainbow: "npm:^3.0.3" + checksum: 10c0/b773bb0133307176762a932bab0480e77c9b378406eb92c82f37a16d214b1d873a0378e4c567c4b05bf4628073bc7fc18b519a7c0bdaf75e18311545ca00c5ba + languageName: node + linkType: hard + +"@vitest/web-worker@npm:^4.0.2": + version: 4.0.2 + resolution: "@vitest/web-worker@npm:4.0.2" + dependencies: + debug: "npm:^4.4.3" + peerDependencies: + vitest: 4.0.2 + checksum: 10c0/d30b32b8c7e4df28fbe099125e62f3fc3d0e48a852b3749cbb47963e33070a4c04b3f55bfaf1f90109eacd6bc4d13b4b8028be984033edda532a8372a5abed86 languageName: node linkType: hard @@ -6421,13 +6471,6 @@ __metadata: languageName: node linkType: hard -"assertion-error@npm:^2.0.1": - version: 2.0.1 - resolution: "assertion-error@npm:2.0.1" - checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 - languageName: node - linkType: hard - "ast-types-flow@npm:^0.0.8": version: 0.0.8 resolution: "ast-types-flow@npm:0.0.8" @@ -6756,13 +6799,6 @@ __metadata: languageName: node linkType: hard -"cac@npm:^6.7.14": - version: 6.7.14 - resolution: "cac@npm:6.7.14" - checksum: 10c0/4ee06aaa7bab8981f0d54e5f5f9d4adcd64058e9697563ce336d8a3878ed018ee18ebe5359b2430eceae87e0758e62ea2019c3f52ae6e211b1bd2e133856cd10 - languageName: node - linkType: hard - "cacache@npm:^16.0.0, cacache@npm:^16.1.0, cacache@npm:^16.1.3": version: 16.1.3 resolution: "cacache@npm:16.1.3" @@ -6950,9 +6986,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.30001688": - version: 1.0.30001712 - resolution: "caniuse-lite@npm:1.0.30001712" - checksum: 10c0/b3df8bdcc3335969380c2e47acb36c89bfc7f8fb4ef7ee2a5380e30ba46aa69e9d411654bc29894a06c201a1d60d490ab9b92787f3b66d7a7a38d71360e68215 + version: 1.0.30001751 + resolution: "caniuse-lite@npm:1.0.30001751" + checksum: 10c0/c3f2d448f3569004ace160fd9379ea0def8e7a7bc6e65611baadb57d24e1f418258647a6210e46732419f5663e2356c22aa841f92449dd3849eb6471bb7ad592 languageName: node linkType: hard @@ -6968,16 +7004,10 @@ __metadata: languageName: node linkType: hard -"chai@npm:^5.2.0": - version: 5.2.1 - resolution: "chai@npm:5.2.1" - dependencies: - assertion-error: "npm:^2.0.1" - check-error: "npm:^2.1.1" - deep-eql: "npm:^5.0.1" - loupe: "npm:^3.1.0" - pathval: "npm:^2.0.0" - checksum: 10c0/58209c03ae9b2fd97cfa1cb0fbe372b1906e6091311b9ba1b0468cc4923b0766a50a1050a164df3ccefb9464944c9216b632f1477c9e429068013bdbb57220f6 +"chai@npm:^6.0.1": + version: 6.2.0 + resolution: "chai@npm:6.2.0" + checksum: 10c0/a4b7d7f5907187e09f1847afa838d6d1608adc7d822031b7900813c4ed5d9702911ac2468bf290676f22fddb3d727b1be90b57c1d0a69b902534ee29cdc6ff8a languageName: node linkType: hard @@ -7048,13 +7078,6 @@ __metadata: languageName: node linkType: hard -"check-error@npm:^2.1.1": - version: 2.1.1 - resolution: "check-error@npm:2.1.1" - checksum: 10c0/979f13eccab306cf1785fa10941a590b4e7ea9916ea2a4f8c87f0316fc3eab07eabefb6e587424ef0f88cbcd3805791f172ea739863ca3d7ce2afc54641c7f0e - languageName: node - linkType: hard - "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -7761,6 +7784,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/d79136ec6c83ecbefd0f6a5593da6a9c91ec4d7ddc4b54c883d6e71ec9accb5f67a1a5e96d00a328196b5b5c86d365e98d8a3a70856aaf16b4e7b1985e67f5a6 + languageName: node + linkType: hard + "debuglog@npm:^1.0.1": version: 1.0.1 resolution: "debuglog@npm:1.0.1" @@ -7815,13 +7850,6 @@ __metadata: languageName: node linkType: hard -"deep-eql@npm:^5.0.1": - version: 5.0.2 - resolution: "deep-eql@npm:5.0.2" - checksum: 10c0/7102cf3b7bb719c6b9c0db2e19bf0aa9318d141581befe8c7ce8ccd39af9eaa4346e5e05adef7f9bd7015da0f13a3a25dcfe306ef79dc8668aedbecb658dd247 - languageName: node - linkType: hard - "deep-equal@npm:^2.0.5": version: 2.2.3 resolution: "deep-equal@npm:2.2.3" @@ -9193,7 +9221,7 @@ __metadata: languageName: node linkType: hard -"expect-type@npm:^1.2.1": +"expect-type@npm:^1.2.2": version: 1.2.2 resolution: "expect-type@npm:1.2.2" checksum: 10c0/6019019566063bbc7a690d9281d920b1a91284a4a093c2d55d71ffade5ac890cf37a51e1da4602546c4b56569d2ad2fc175a2ccee77d1ae06cb3af91ef84f44b @@ -9293,7 +9321,7 @@ __metadata: languageName: node linkType: hard -"fdir@npm:^6.4.4, fdir@npm:^6.4.6": +"fdir@npm:^6.4.4": version: 6.4.6 resolution: "fdir@npm:6.4.6" peerDependencies: @@ -9305,6 +9333,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + "figures@npm:^1.7.0": version: 1.7.0 resolution: "figures@npm:1.7.0" @@ -12209,13 +12249,6 @@ __metadata: languageName: node linkType: hard -"loupe@npm:^3.1.0, loupe@npm:^3.1.4": - version: 3.2.0 - resolution: "loupe@npm:3.2.0" - checksum: 10c0/f572fd9e38db8d36ae9eede305480686e310d69bc40394b6842838ebc6c3860a0e35ab30182f33606ab2d8a685d9ff6436649269f8218a1c3385ca329973cb2c - languageName: node - linkType: hard - "lowercase-keys@npm:^2.0.0": version: 2.0.0 resolution: "lowercase-keys@npm:2.0.0" @@ -12289,6 +12322,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.19": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + "magicast@npm:^0.3.5": version: 0.3.5 resolution: "magicast@npm:0.3.5" @@ -14099,13 +14141,6 @@ __metadata: languageName: node linkType: hard -"pathval@npm:^2.0.0": - version: 2.0.1 - resolution: "pathval@npm:2.0.1" - checksum: 10c0/460f4709479fbf2c45903a65655fc8f0a5f6d808f989173aeef5fdea4ff4f303dc13f7870303999add60ec49d4c14733895c0a869392e9866f1091fa64fd7581 - languageName: node - linkType: hard - "picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" @@ -15326,30 +15361,32 @@ __metadata: languageName: node linkType: hard -"rollup@npm:^4.40.0": - version: 4.46.1 - resolution: "rollup@npm:4.46.1" +"rollup@npm:^4.43.0": + version: 4.52.5 + resolution: "rollup@npm:4.52.5" dependencies: - "@rollup/rollup-android-arm-eabi": "npm:4.46.1" - "@rollup/rollup-android-arm64": "npm:4.46.1" - "@rollup/rollup-darwin-arm64": "npm:4.46.1" - "@rollup/rollup-darwin-x64": "npm:4.46.1" - "@rollup/rollup-freebsd-arm64": "npm:4.46.1" - "@rollup/rollup-freebsd-x64": "npm:4.46.1" - "@rollup/rollup-linux-arm-gnueabihf": "npm:4.46.1" - "@rollup/rollup-linux-arm-musleabihf": "npm:4.46.1" - "@rollup/rollup-linux-arm64-gnu": "npm:4.46.1" - "@rollup/rollup-linux-arm64-musl": "npm:4.46.1" - "@rollup/rollup-linux-loongarch64-gnu": "npm:4.46.1" - "@rollup/rollup-linux-ppc64-gnu": "npm:4.46.1" - "@rollup/rollup-linux-riscv64-gnu": "npm:4.46.1" - "@rollup/rollup-linux-riscv64-musl": "npm:4.46.1" - "@rollup/rollup-linux-s390x-gnu": "npm:4.46.1" - "@rollup/rollup-linux-x64-gnu": "npm:4.46.1" - "@rollup/rollup-linux-x64-musl": "npm:4.46.1" - "@rollup/rollup-win32-arm64-msvc": "npm:4.46.1" - "@rollup/rollup-win32-ia32-msvc": "npm:4.46.1" - "@rollup/rollup-win32-x64-msvc": "npm:4.46.1" + "@rollup/rollup-android-arm-eabi": "npm:4.52.5" + "@rollup/rollup-android-arm64": "npm:4.52.5" + "@rollup/rollup-darwin-arm64": "npm:4.52.5" + "@rollup/rollup-darwin-x64": "npm:4.52.5" + "@rollup/rollup-freebsd-arm64": "npm:4.52.5" + "@rollup/rollup-freebsd-x64": "npm:4.52.5" + "@rollup/rollup-linux-arm-gnueabihf": "npm:4.52.5" + "@rollup/rollup-linux-arm-musleabihf": "npm:4.52.5" + "@rollup/rollup-linux-arm64-gnu": "npm:4.52.5" + "@rollup/rollup-linux-arm64-musl": "npm:4.52.5" + "@rollup/rollup-linux-loong64-gnu": "npm:4.52.5" + "@rollup/rollup-linux-ppc64-gnu": "npm:4.52.5" + "@rollup/rollup-linux-riscv64-gnu": "npm:4.52.5" + "@rollup/rollup-linux-riscv64-musl": "npm:4.52.5" + "@rollup/rollup-linux-s390x-gnu": "npm:4.52.5" + "@rollup/rollup-linux-x64-gnu": "npm:4.52.5" + "@rollup/rollup-linux-x64-musl": "npm:4.52.5" + "@rollup/rollup-openharmony-arm64": "npm:4.52.5" + "@rollup/rollup-win32-arm64-msvc": "npm:4.52.5" + "@rollup/rollup-win32-ia32-msvc": "npm:4.52.5" + "@rollup/rollup-win32-x64-gnu": "npm:4.52.5" + "@rollup/rollup-win32-x64-msvc": "npm:4.52.5" "@types/estree": "npm:1.0.8" fsevents: "npm:~2.3.2" dependenciesMeta: @@ -15373,7 +15410,7 @@ __metadata: optional: true "@rollup/rollup-linux-arm64-musl": optional: true - "@rollup/rollup-linux-loongarch64-gnu": + "@rollup/rollup-linux-loong64-gnu": optional: true "@rollup/rollup-linux-ppc64-gnu": optional: true @@ -15387,17 +15424,21 @@ __metadata: optional: true "@rollup/rollup-linux-x64-musl": optional: true + "@rollup/rollup-openharmony-arm64": + optional: true "@rollup/rollup-win32-arm64-msvc": optional: true "@rollup/rollup-win32-ia32-msvc": optional: true + "@rollup/rollup-win32-x64-gnu": + optional: true "@rollup/rollup-win32-x64-msvc": optional: true fsevents: optional: true bin: rollup: dist/bin/rollup - checksum: 10c0/84297d63a97bf8fc131039d0f600787ada8baaa38b744820f7d29da601bce9f70a14aa12da80843b2b49eb8660718576f441ee9952954efa16009d7ccebf0000 + checksum: 10c0/faf1697b305d13a149bb64a2bb7378344becc7c8580f56225c4c00adbf493d82480a44b3e3b1cc82a3ac5d1d4cab6dfc89e6635443895a2dc488969075f5b94d languageName: node linkType: hard @@ -16421,15 +16462,6 @@ __metadata: languageName: node linkType: hard -"strip-literal@npm:^3.0.0": - version: 3.0.0 - resolution: "strip-literal@npm:3.0.0" - dependencies: - js-tokens: "npm:^9.0.1" - checksum: 10c0/d81657f84aba42d4bbaf2a677f7e7f34c1f3de5a6726db8bc1797f9c0b303ba54d4660383a74bde43df401cf37cce1dff2c842c55b077a4ceee11f9e31fba828 - languageName: node - linkType: hard - "supports-color@npm:^2.0.0": version: 2.0.0 resolution: "supports-color@npm:2.0.0" @@ -16676,10 +16708,13 @@ __metadata: languageName: node linkType: hard -"tinypool@npm:^1.1.1": - version: 1.1.1 - resolution: "tinypool@npm:1.1.1" - checksum: 10c0/bf26727d01443061b04fa863f571016950888ea994ba0cd8cba3a1c51e2458d84574341ab8dbc3664f1c3ab20885c8cf9ff1cc4b18201f04c2cde7d317fff69b +"tinyglobby@npm:^0.2.15": + version: 0.2.15 + resolution: "tinyglobby@npm:0.2.15" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.3" + checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 languageName: node linkType: hard @@ -16690,10 +16725,10 @@ __metadata: languageName: node linkType: hard -"tinyspy@npm:^4.0.3": - version: 4.0.3 - resolution: "tinyspy@npm:4.0.3" - checksum: 10c0/0a92a18b5350945cc8a1da3a22c9ad9f4e2945df80aaa0c43e1b3a3cfb64d8501e607ebf0305e048e3c3d3e0e7f8eb10cea27dc17c21effb73e66c4a3be36373 +"tinyrainbow@npm:^3.0.3": + version: 3.0.3 + resolution: "tinyrainbow@npm:3.0.3" + checksum: 10c0/1e799d35cd23cabe02e22550985a3051dc88814a979be02dc632a159c393a998628eacfc558e4c746b3006606d54b00bcdea0c39301133956d10a27aa27e988c languageName: node linkType: hard @@ -16755,6 +16790,8 @@ __metadata: resolution: "traefik-proxy-dashboard@workspace:." dependencies: "@eslint/js": "npm:^9.32.0" + "@noble/ed25519": "npm:^3.0.0" + "@noble/hashes": "npm:^2.0.1" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.4.2" "@testing-library/react": "npm:^14.2.1" @@ -16768,6 +16805,7 @@ __metadata: "@typescript-eslint/parser": "npm:^8.38.0" "@vitejs/plugin-react": "npm:^4.7.0" "@vitest/coverage-v8": "npm:^3.2.4" + "@vitest/web-worker": "npm:^4.0.2" chart.js: "npm:^4.4.1" eslint: "npm:^9.32.0" eslint-config-prettier: "npm:^10.1.8" @@ -16800,7 +16838,7 @@ __metadata: usehooks-ts: "npm:^2.14.0" vite: "npm:^5.4.19" vite-tsconfig-paths: "npm:^5.1.4" - vitest: "npm:^3.2.4" + vitest: "npm:^4.0.3" vitest-canvas-mock: "npm:^0.3.3" languageName: unknown linkType: soft @@ -17519,21 +17557,6 @@ __metadata: languageName: node linkType: hard -"vite-node@npm:3.2.4": - version: 3.2.4 - resolution: "vite-node@npm:3.2.4" - dependencies: - cac: "npm:^6.7.14" - debug: "npm:^4.4.1" - es-module-lexer: "npm:^1.7.0" - pathe: "npm:^2.0.3" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - bin: - vite-node: vite-node.mjs - checksum: 10c0/6ceca67c002f8ef6397d58b9539f80f2b5d79e103a18367288b3f00a8ab55affa3d711d86d9112fce5a7fa658a212a087a005a045eb8f4758947dd99af2a6c6b - languageName: node - linkType: hard - "vite-tsconfig-paths@npm:^5.1.4": version: 5.1.4 resolution: "vite-tsconfig-paths@npm:5.1.4" @@ -17550,61 +17573,6 @@ __metadata: languageName: node linkType: hard -"vite@npm:^5.0.0 || ^6.0.0 || ^7.0.0-0": - version: 7.0.6 - resolution: "vite@npm:7.0.6" - dependencies: - esbuild: "npm:^0.25.0" - fdir: "npm:^6.4.6" - fsevents: "npm:~2.3.3" - picomatch: "npm:^4.0.3" - postcss: "npm:^8.5.6" - rollup: "npm:^4.40.0" - tinyglobby: "npm:^0.2.14" - peerDependencies: - "@types/node": ^20.19.0 || >=22.12.0 - jiti: ">=1.21.0" - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: ">=0.54.8" - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - dependenciesMeta: - fsevents: - optional: true - peerDependenciesMeta: - "@types/node": - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - bin: - vite: bin/vite.js - checksum: 10c0/3b14dfa661281b4843789884199ba2a9cca940a7666970036fe3fb1abff52b88e63e8be5ab419dd04d9f96c0415ee0f1e3ec8ebe357041648af7ccd8e348b6ad - languageName: node - linkType: hard - "vite@npm:^5.1.5": version: 5.4.17 resolution: "vite@npm:5.4.17" @@ -17691,6 +17659,61 @@ __metadata: languageName: node linkType: hard +"vite@npm:^6.0.0 || ^7.0.0": + version: 7.1.12 + resolution: "vite@npm:7.1.12" + dependencies: + esbuild: "npm:^0.25.0" + fdir: "npm:^6.5.0" + fsevents: "npm:~2.3.3" + picomatch: "npm:^4.0.3" + postcss: "npm:^8.5.6" + rollup: "npm:^4.43.0" + tinyglobby: "npm:^0.2.15" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/cef4d4b4a84e663e09b858964af36e916892ac8540068df42a05ced637ceeae5e9ef71c72d54f3cfc1f3c254af16634230e221b6e2327c2a66d794bb49203262 + languageName: node + linkType: hard + "vitest-canvas-mock@npm:^0.3.3": version: 0.3.3 resolution: "vitest-canvas-mock@npm:0.3.3" @@ -17702,39 +17725,38 @@ __metadata: languageName: node linkType: hard -"vitest@npm:^3.2.4": - version: 3.2.4 - resolution: "vitest@npm:3.2.4" +"vitest@npm:^4.0.3": + version: 4.0.3 + resolution: "vitest@npm:4.0.3" dependencies: - "@types/chai": "npm:^5.2.2" - "@vitest/expect": "npm:3.2.4" - "@vitest/mocker": "npm:3.2.4" - "@vitest/pretty-format": "npm:^3.2.4" - "@vitest/runner": "npm:3.2.4" - "@vitest/snapshot": "npm:3.2.4" - "@vitest/spy": "npm:3.2.4" - "@vitest/utils": "npm:3.2.4" - chai: "npm:^5.2.0" - debug: "npm:^4.4.1" - expect-type: "npm:^1.2.1" - magic-string: "npm:^0.30.17" + "@vitest/expect": "npm:4.0.3" + "@vitest/mocker": "npm:4.0.3" + "@vitest/pretty-format": "npm:4.0.3" + "@vitest/runner": "npm:4.0.3" + "@vitest/snapshot": "npm:4.0.3" + "@vitest/spy": "npm:4.0.3" + "@vitest/utils": "npm:4.0.3" + debug: "npm:^4.4.3" + es-module-lexer: "npm:^1.7.0" + expect-type: "npm:^1.2.2" + magic-string: "npm:^0.30.19" pathe: "npm:^2.0.3" - picomatch: "npm:^4.0.2" + picomatch: "npm:^4.0.3" std-env: "npm:^3.9.0" tinybench: "npm:^2.9.0" tinyexec: "npm:^0.3.2" - tinyglobby: "npm:^0.2.14" - tinypool: "npm:^1.1.1" - tinyrainbow: "npm:^2.0.0" - vite: "npm:^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node: "npm:3.2.4" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.0.3" + vite: "npm:^6.0.0 || ^7.0.0" why-is-node-running: "npm:^2.3.0" peerDependencies: "@edge-runtime/vm": "*" "@types/debug": ^4.1.12 - "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 - "@vitest/browser": 3.2.4 - "@vitest/ui": 3.2.4 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.0.3 + "@vitest/browser-preview": 4.0.3 + "@vitest/browser-webdriverio": 4.0.3 + "@vitest/ui": 4.0.3 happy-dom: "*" jsdom: "*" peerDependenciesMeta: @@ -17744,7 +17766,11 @@ __metadata: optional: true "@types/node": optional: true - "@vitest/browser": + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": optional: true "@vitest/ui": optional: true @@ -17754,7 +17780,7 @@ __metadata: optional: true bin: vitest: vitest.mjs - checksum: 10c0/5bf53ede3ae6a0e08956d72dab279ae90503f6b5a05298a6a5e6ef47d2fd1ab386aaf48fafa61ed07a0ebfe9e371772f1ccbe5c258dd765206a8218bf2eb79eb + checksum: 10c0/3462e797fc3100d6adae9d5beaa4784a109d4fe5fc1403fb251ebdf7ca95988c1f1447bbaeac3f0046aae555a611f1dcb9da0bcb9d30af7ee99ac5392427fd2b languageName: node linkType: hard