1
0
Fork 0

Migrate Traefik Proxy dashboard UI to React

This commit is contained in:
Gina A. 2025-05-28 11:26:04 +02:00 committed by GitHub
parent 4790e4910f
commit f16fff577a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
324 changed files with 28303 additions and 19567 deletions

View file

@ -0,0 +1,195 @@
import { act, fireEvent, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { SWRConfig } from 'swr'
import useFetchWithPagination from './use-fetch-with-pagination'
import { server } from 'mocks/server'
import { renderWithProviders } from 'utils/test'
const renderRow = (row) => (
<li key={row.id} data-testid="listRow">
{row.id}
</li>
)
const wrapper = ({ children }) => (
<SWRConfig
value={{
revalidateOnFocus: false,
fetcher: fetch,
}}
>
{children}
</SWRConfig>
)
describe('useFetchWithPagination Hook', () => {
it('should fetch 1st page per default', async () => {
server.use(
http.get('/api/http/routers', () => {
return HttpResponse.json([{ id: 1 }], { status: 200 })
}),
)
const { result } = renderHook(() => useFetchWithPagination('/http/routers', { renderRow }), {
wrapper,
})
await waitFor(() => {
expect(result.current.pages).not.toBeUndefined()
})
})
it('should work as expected passing rowsPerPage property', async () => {
let perPage
server.use(
http.get('/api/http/routers', ({ request }) => {
const url = new URL(request.url)
perPage = url.searchParams.get('per_page')
return HttpResponse.json([{ id: 1 }], { status: 200 })
}),
)
const { result } = renderHook(() => useFetchWithPagination('/http/routers', { renderRow, rowsPerPage: 3 }), {
wrapper,
})
await waitFor(() => {
expect(result.current.pages).not.toBeUndefined()
})
expect(perPage).toBe('3')
})
it('should work as expected requesting page 2', async () => {
server.use(
http.get('/api/http/routers', ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page')
if (page === '2') {
return HttpResponse.json([{ id: 3 }], {
headers: {
'X-Next-Page': '1',
},
status: 200,
})
}
return HttpResponse.json([{ id: 1 }, { id: 2 }], {
headers: {
'X-Next-Page': '2',
},
status: 200,
})
}),
)
const TestComponent = () => {
const { pages, pageCount, loadMore, isLoadingMore } = useFetchWithPagination('/http/routers', {
renderLoader: () => null,
renderRow,
rowsPerPage: 2,
})
return (
<>
<ul>{pages}</ul>
{isLoadingMore ? <div data-testid="loading">Loading...</div> : <button onClick={loadMore}>Load More</button>}
<div data-testid="pageCount">{pageCount}</div>
</>
)
}
const { queryAllByTestId, getByTestId, getByText } = renderWithProviders(<TestComponent />)
await waitFor(() => {
expect(() => {
getByTestId('loading')
}).toThrow('Unable to find an element by: [data-testid="loading"]')
})
act(() => {
fireEvent.click(getByText(/Load More/))
})
await waitFor(() => {
expect(() => {
getByTestId('loading')
}).toThrow('Unable to find an element by: [data-testid="loading"]')
})
expect(getByTestId('pageCount').innerHTML).toBe('2')
const items = await queryAllByTestId('listRow')
expect(items).toHaveLength(3)
})
it('should work as expected requesting an empty page', async () => {
server.use(
http.get('/api/http/routers', ({ request }) => {
const url = new URL(request.url)
const page = url.searchParams.get('page')
if (page === '2') {
return HttpResponse.json(
// Response body should be { message: 'invalid request: page: 2, per_page: 4' }, resulting in a type error.
// If I type the response body accordingly, allowing both an array and an object, MSW breaks, so I replaced
// the object with an empty array, and that'd be enough for testing purpose.
[],
{
headers: {
'X-Next-Page': '1',
},
status: 200,
},
)
}
return HttpResponse.json([{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], {
headers: {
'X-Next-Page': '2',
},
status: 200,
})
}),
)
const TestComponent = () => {
const { pages, pageCount, loadMore, isLoadingMore } = useFetchWithPagination('/http/routers', {
renderLoader: () => null,
renderRow,
rowsPerPage: 4,
})
return (
<>
<ul>{pages}</ul>
{isLoadingMore ? <div data-testid="loading">Loading...</div> : <button onClick={loadMore}>Load More</button>}
<div data-testid="pageCount">{pageCount}</div>
</>
)
}
const { queryAllByTestId, getByTestId, getByText } = renderWithProviders(<TestComponent />)
await waitFor(() => {
expect(() => {
getByTestId('loading')
}).toThrow('Unable to find an element by: [data-testid="loading"]')
})
act(() => {
fireEvent.click(getByText(/Load More/))
})
await waitFor(() => {
expect(() => {
getByTestId('loading')
}).toThrow('Unable to find an element by: [data-testid="loading"]')
})
expect(getByTestId('pageCount').innerHTML).toBe('2')
const items = await queryAllByTestId('listRow')
expect(items).toHaveLength(4)
})
})

View file

@ -0,0 +1,89 @@
import { AriaTd, AriaTr } from '@traefiklabs/faency'
import { stringify } from 'query-string'
import { ReactNode } from 'react'
import useSWRInfinite, { SWRInfiniteConfiguration } from 'swr/infinite'
import { fetchPage } from 'libs/fetch'
export type RenderRowType = (row: Record<string, unknown>) => ReactNode
export type pagesResponseInterface = {
pages: ReactNode
pageCount: number
error?: Error | null
isLoadingMore: boolean
isReachingEnd: boolean
isEmpty: boolean
loadMore: () => void
}
type useFetchWithPaginationType = (
path: string,
opts: SWRInfiniteConfiguration & {
rowsPerPage?: number
renderRow: RenderRowType
renderLoader?: () => ReactNode
listContextKey?: string
query?: Record<string, unknown>
},
) => pagesResponseInterface
const useFetchWithPagination: useFetchWithPaginationType = (path, opts) => {
const defaultLoadingFunction = () => (
<AriaTr>
<AriaTd>Loading...</AriaTd>
</AriaTr>
)
const { rowsPerPage = 10, renderLoader = defaultLoadingFunction, renderRow, query } = opts
const getKey = (
pageIndex: number,
previousPageData: { data?: unknown[]; nextPage?: number } | null,
): string | null => {
if (previousPageData && (!previousPageData.data?.length || previousPageData.nextPage === 1)) return null
return `${path}?${stringify({
page: pageIndex + 1,
per_page: rowsPerPage,
...query,
})}`
}
const { data: res, error, size, setSize } = useSWRInfinite<{ data?: unknown[]; nextPage?: number }>(getKey, fetchPage)
const isLoadingInitialData = !res && !error
const isEmpty = !res?.[0]?.data || (Array.isArray(res?.[0]?.data) && res?.[0]?.data.length === 0)
const isLoadingMore = isLoadingInitialData || (size > 0 && res && typeof res[size - 1] === 'undefined') || false
const nextPage = res?.[size - 1]?.nextPage
const isReachingEnd = !nextPage || nextPage === 1
const loadMore = (): void => {
if (!isLoadingMore) {
setSize(size + 1)
}
}
const data = res?.reduce((acc: unknown[], req) => {
if (req.data) {
acc.push(...req.data)
}
return acc
}, [] as unknown[])
let pages: ReactNode = null
if (!error) {
pages = !data ? renderLoader() : (data as Record<string, unknown>[]).map(renderRow)
}
return {
pages,
pageCount: size,
isEmpty,
error,
isLoadingMore,
isReachingEnd,
loadMore,
}
}
export default useFetchWithPagination

View file

@ -0,0 +1,36 @@
import useSWR from 'swr'
type TotalsResultItem = {
routers: number
services: number
middlewares?: number
}
type TotalsResult = {
http: TotalsResultItem
tcp: TotalsResultItem
udp: TotalsResultItem
}
const useTotals = (): TotalsResult => {
const { data } = useSWR('/overview')
return {
http: {
routers: data?.http?.routers?.total,
services: data?.http?.services?.total,
middlewares: data?.http?.middlewares?.total,
},
tcp: {
routers: data?.tcp?.routers?.total,
services: data?.tcp?.services?.total,
middlewares: data?.tcp?.middlewares?.total,
},
udp: {
routers: data?.udp?.routers?.total,
services: data?.udp?.services?.total,
},
}
}
export default useTotals

View file

@ -0,0 +1,13 @@
import { useEffect, useRef } from 'react'
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => {
ref.current = value
}, [value])
return ref.current
}
export default usePrevious

View file

@ -0,0 +1,60 @@
import { renderHook, waitFor } from '@testing-library/react'
import { SWRConfig } from 'swr'
import { useResourceDetail } from './use-resource-detail'
import fetch from 'libs/fetch'
describe('useResourceDetail', () => {
it('should fetch information about entrypoints and middlewares', async () => {
const { result } = renderHook(() => useResourceDetail('server-redirect@docker', 'routers'), {
wrapper: ({ children }) => (
<SWRConfig
value={{
revalidateOnFocus: false,
fetcher: fetch,
}}
>
{children}
</SWRConfig>
),
})
await waitFor(() => {
expect(result.current.data).not.toBeUndefined()
})
const { data } = result.current
expect(data?.name).toBe('server-redirect@docker')
expect(data?.service).toBe('api2_v2-example-beta1')
expect(data?.status).toBe('enabled')
expect(data?.provider).toBe('docker')
expect(data?.rule).toBe('Host(`server`)')
expect(data?.tls).toBeUndefined()
expect(data?.error).toBeUndefined()
expect(data?.middlewares?.length).toBe(1)
expect(data?.middlewares?.[0]).toEqual({
redirectScheme: {
scheme: 'https',
},
status: 'enabled',
usedBy: ['server-mtls@docker', 'server-redirect@docker', 'orphan-router@file'],
name: 'redirect@file',
type: 'redirectscheme',
provider: 'file',
})
expect(data?.hasValidMiddlewares).toBeTrue()
expect(data?.entryPointsData?.length).toBe(1)
expect(data?.entryPointsData?.[0]).toEqual({
address: ':80',
transport: {
lifeCycle: { graceTimeOut: 10000000000 },
respondingTimeouts: { idleTimeout: 180000000000 },
},
forwardedHeaders: {},
name: 'web-redirect',
})
expect(data?.using?.length).toBe(1)
expect(data?.using?.[0]).toEqual('web-redirect')
})
})

View file

@ -0,0 +1,180 @@
import useSWR from 'swr'
import fetchMany from 'libs/fetchMany'
export type EntryPoint = {
name: string
address: string
message?: string
}
type JSONObject = {
[x: string]: string | number
}
export type ValuesMapType = {
[key: string]: string | number | JSONObject
}
export type MiddlewareProps = {
[prop: string]: ValuesMapType
}
export type Middleware = {
name: string
status: 'enabled' | 'disabled' | 'warning'
provider: string
type?: string
plugin?: Record<string, unknown>
error?: string[]
routers?: string[]
usedBy?: string[]
} & MiddlewareProps
type Router = {
name: string
service?: string
status: 'enabled' | 'disabled' | 'warning'
rule?: string
priority?: number
provider: string
tls?: {
options: string
certResolver: string
domains: TlsDomain[]
passthrough: boolean
}
error?: string[]
entryPoints?: string[]
message?: string
}
type TlsDomain = {
main: string
sans: string[]
}
export type RouterDetailType = Router & {
middlewares?: Middleware[]
hasValidMiddlewares?: boolean
entryPointsData?: EntryPoint[]
using?: string[]
}
type Mirror = {
name: string
percent: number
}
export type ServiceDetailType = {
name: string
status: 'enabled' | 'disabled' | 'warning'
provider: string
type: string
usedBy?: string[]
routers?: Router[]
serverStatus?: {
[server: string]: string
}
mirroring?: {
service: string
mirrors?: Mirror[]
}
loadBalancer?: {
servers?: { url: string }[]
passHostHeader?: boolean
terminationDelay?: number
healthCheck?: {
scheme: string
path: string
port: number
interval: string
timeout: string
hostname: string
headers?: {
[header: string]: string
}
}
}
weighted?: {
services?: {
name: string
weight: number
}[]
}
}
export type MiddlewareDetailType = Middleware & {
routers?: Router[]
}
export type ResourceDetailDataType = RouterDetailType & ServiceDetailType & MiddlewareDetailType
type ResourceDetailType = {
data?: ResourceDetailDataType
error?: Error
}
export const useResourceDetail = (name: string, resource: string, protocol = 'http'): ResourceDetailType => {
const { data: routeDetail, error } = useSWR(`/${protocol}/${resource}/${name}`)
const { data: entryPoints, error: entryPointsError } = useSWR(() => ['/entrypoints/', routeDetail.using], fetchMany)
const { data: middlewares, error: middlewaresError } = useSWR(
() => [`/${protocol}/middlewares/`, routeDetail.middlewares],
fetchMany,
)
const { data: routers, error: routersError } = useSWR(() => [`/${protocol}/routers/`, routeDetail.usedBy], fetchMany)
if (!routeDetail) {
return { error }
}
const firstError = error || entryPointsError || middlewaresError || routersError
const validMiddlewares = (middlewares as Middleware[] | undefined)?.filter((mw) => !!mw.name)
const hasMiddlewares = validMiddlewares
? validMiddlewares.length > 0
: routeDetail.middlewares && routeDetail.middlewares.length > 0
if (resource === 'routers') {
return {
data: {
name: routeDetail.name,
service: routeDetail.service,
status: routeDetail.status,
provider: routeDetail.provider,
rule: routeDetail.rule,
tls: routeDetail.tls,
error: routeDetail.error,
middlewares: validMiddlewares,
hasValidMiddlewares: hasMiddlewares,
entryPointsData: entryPoints,
using: routeDetail.using,
},
error: firstError,
} as ResourceDetailType
}
if (resource === 'middlewares') {
return {
data: {
...routeDetail,
routers,
},
error: firstError,
} as ResourceDetailType
}
return {
data: {
name: routeDetail.name,
status: routeDetail.status,
provider: routeDetail.provider,
type: routeDetail.type,
loadBalancer: routeDetail.loadBalancer,
mirroring: routeDetail.mirroring,
serverStatus: routeDetail.serverStatus,
usedBy: routeDetail.usedBy,
weighted: routeDetail.weighted,
routers,
},
error: firstError,
} as ResourceDetailType
}

View file

@ -0,0 +1,49 @@
import { useMemo } from 'react'
import { useLocalStorage } from 'usehooks-ts'
const SYSTEM = 'system'
const DARK = 'dark'
const LIGHT = 'light'
type ThemeOptions = 'system' | 'dark' | 'light'
const THEME_OPTIONS: ThemeOptions[] = [SYSTEM, DARK, LIGHT]
type UseThemeRes = {
selectedTheme: ThemeOptions
appliedTheme: ThemeOptions
setTheme: () => void
}
export const useTheme = (): UseThemeRes => {
const [selectedTheme, setSelectedTheme] = useLocalStorage<ThemeOptions>('selected-theme', SYSTEM)
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
const appliedTheme = useMemo(() => {
if (selectedTheme === SYSTEM) {
if (prefersDark) return DARK
return LIGHT
}
return selectedTheme
}, [selectedTheme, prefersDark])
return {
selectedTheme,
appliedTheme,
setTheme: () => {
setSelectedTheme((curr: ThemeOptions): ThemeOptions => {
const currIdx = THEME_OPTIONS.indexOf(curr)
const nextIdx = currIdx + 1
if (nextIdx === THEME_OPTIONS.length) return SYSTEM
return THEME_OPTIONS[nextIdx]
})
},
}
}
export const useIsDarkMode = () => {
const { appliedTheme } = useTheme()
return appliedTheme === DARK
}

View file

@ -0,0 +1,13 @@
import { useMemo } from 'react'
import useSWR from 'swr'
export default function useVersion() {
const { data: version } = useSWR('/version')
const showHubButton = useMemo(() => {
if (!version) return false
return !version?.disableDashboardAd
}, [version])
return { showHubButton, version }
}