Migrate Traefik Proxy dashboard UI to React
This commit is contained in:
parent
4790e4910f
commit
f16fff577a
324 changed files with 28303 additions and 19567 deletions
|
|
@ -1,109 +0,0 @@
|
|||
<template>
|
||||
<page-default>
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-xl q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg">
|
||||
<tool-bar-table
|
||||
v-model:status="status"
|
||||
v-model:filter="filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<main-table
|
||||
ref="mainTable"
|
||||
v-bind="getTableProps({ type: 'tcp-middlewares' })"
|
||||
v-model:current-sort="sortBy"
|
||||
v-model:current-sort-dir="sortDir"
|
||||
:data="allMiddlewares.items"
|
||||
:on-load-more="handleLoadMore"
|
||||
:end-reached="allMiddlewares.endReached"
|
||||
:loading="allMiddlewares.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import GetTablePropsMixin from '../../_mixins/GetTableProps'
|
||||
import PaginationMixin from '../../_mixins/Pagination'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import ToolBarTable from '../../components/_commons/ToolBarTable.vue'
|
||||
import MainTable from '../../components/_commons/MainTable.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageTCPMiddlewares',
|
||||
components: {
|
||||
PageDefault,
|
||||
ToolBarTable,
|
||||
MainTable
|
||||
},
|
||||
mixins: [
|
||||
GetTablePropsMixin,
|
||||
PaginationMixin({
|
||||
fetchMethod: 'getAllMiddlewaresWithParams',
|
||||
scrollerRef: 'mainTable.$refs.scroller',
|
||||
pollingIntervalTime: 5000
|
||||
})
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
filter: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('tcp', { allMiddlewares: 'allMiddlewares' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('tcp/getAllMiddlewaresClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('tcp', { getAllMiddlewares: 'getAllMiddlewares' }),
|
||||
getAllMiddlewaresWithParams (params) {
|
||||
return this.getAllMiddlewares({
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir,
|
||||
...params
|
||||
})
|
||||
},
|
||||
refreshAll () {
|
||||
if (this.allMiddlewares.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initFetch()
|
||||
},
|
||||
handleLoadMore ({ page = 1 } = {}) {
|
||||
return this.fetchMore({ page })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
<template>
|
||||
<page-default>
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-xl q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg">
|
||||
<tool-bar-table
|
||||
v-model:status="status"
|
||||
v-model:filter="filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<main-table
|
||||
ref="mainTable"
|
||||
v-bind="getTableProps({ type: 'tcp-routers' })"
|
||||
v-model:current-sort="sortBy"
|
||||
v-model:current-sort-dir="sortDir"
|
||||
:data="allRouters.items"
|
||||
:on-load-more="handleLoadMore"
|
||||
:end-reached="allRouters.endReached"
|
||||
:loading="allRouters.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import GetTablePropsMixin from '../../_mixins/GetTableProps'
|
||||
import PaginationMixin from '../../_mixins/Pagination'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import ToolBarTable from '../../components/_commons/ToolBarTable.vue'
|
||||
import MainTable from '../../components/_commons/MainTable.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageTCPRouters',
|
||||
components: {
|
||||
PageDefault,
|
||||
ToolBarTable,
|
||||
MainTable
|
||||
},
|
||||
mixins: [
|
||||
GetTablePropsMixin,
|
||||
PaginationMixin({
|
||||
fetchMethod: 'getAllRoutersWithParams',
|
||||
scrollerRef: 'mainTable.$refs.scroller',
|
||||
pollingIntervalTime: 5000
|
||||
})
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
filter: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('tcp', { allRouters: 'allRouters' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('tcp/getAllRoutersClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('tcp', { getAllRouters: 'getAllRouters' }),
|
||||
getAllRoutersWithParams (params) {
|
||||
return this.getAllRouters({
|
||||
serviceName: '',
|
||||
middlewareName: '',
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir,
|
||||
...params
|
||||
})
|
||||
},
|
||||
refreshAll () {
|
||||
if (this.allRouters.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initFetch()
|
||||
},
|
||||
handleLoadMore ({ page = 1 } = {}) {
|
||||
return this.fetchMore({ page })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<template>
|
||||
<page-default>
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-xl q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg">
|
||||
<tool-bar-table
|
||||
v-model:status="status"
|
||||
v-model:filter="filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<main-table
|
||||
ref="mainTable"
|
||||
v-bind="getTableProps({ type: 'tcp-services' })"
|
||||
v-model:current-sort="sortBy"
|
||||
v-model:current-sort-dir="sortDir"
|
||||
:data="allServices.items"
|
||||
:on-load-more="handleLoadMore"
|
||||
:end-reached="allServices.endReached"
|
||||
:loading="allServices.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import GetTablePropsMixin from '../../_mixins/GetTableProps'
|
||||
import PaginationMixin from '../../_mixins/Pagination'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import ToolBarTable from '../../components/_commons/ToolBarTable.vue'
|
||||
import MainTable from '../../components/_commons/MainTable.vue'
|
||||
|
||||
export default {
|
||||
name: 'PageTCPServices',
|
||||
components: {
|
||||
PageDefault,
|
||||
ToolBarTable,
|
||||
MainTable
|
||||
},
|
||||
mixins: [
|
||||
GetTablePropsMixin,
|
||||
PaginationMixin({
|
||||
fetchMethod: 'getAllServicesWithParams',
|
||||
scrollerRef: 'mainTable.$refs.scroller',
|
||||
pollingIntervalTime: 5000
|
||||
})
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
filter: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('tcp', { allServices: 'allServices' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('tcp/getAllServicesClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('tcp', { getAllServices: 'getAllServices' }),
|
||||
getAllServicesWithParams (params) {
|
||||
return this.getAllServices({
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir,
|
||||
...params
|
||||
})
|
||||
},
|
||||
refreshAll () {
|
||||
if (this.allServices.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initFetch()
|
||||
},
|
||||
handleLoadMore ({ page = 1 } = {}) {
|
||||
return this.fetchMore({ page })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
128
webui/src/pages/tcp/TcpMiddleware.spec.tsx
Normal file
128
webui/src/pages/tcp/TcpMiddleware.spec.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { TcpMiddlewareRender } from './TcpMiddleware'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpMiddlewarePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a simple middleware', async () => {
|
||||
const mockData = {
|
||||
inFlightConn: {
|
||||
amount: 10,
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['router-test-simple@docker'],
|
||||
name: 'middleware-simple',
|
||||
provider: 'docker',
|
||||
type: 'addprefix',
|
||||
routers: [
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['middleware-simple'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'router-test-simple@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'middleware-simple')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
||||
expect(middlewareCard.innerHTML).toContain('amount')
|
||||
expect(middlewareCard.innerHTML).toContain('10')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-simple@docker')
|
||||
})
|
||||
|
||||
it('should render a complex middleware', async () => {
|
||||
const mockData = {
|
||||
name: 'middleware-complex',
|
||||
type: 'sample-middleware',
|
||||
status: 'enabled',
|
||||
provider: 'the-provider',
|
||||
usedBy: ['router-test-complex@docker'],
|
||||
inFlightConn: {
|
||||
amount: 10,
|
||||
},
|
||||
ipWhiteList: {
|
||||
sourceRange: ['125.0.0.1', '125.0.0.4'],
|
||||
},
|
||||
routers: [
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['middleware-complex'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'router-test-complex@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpMiddlewareRender name="mock-middleware" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'middleware-complex')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('the-provider')
|
||||
expect(middlewareCard.innerHTML).toContain('inFlightConn')
|
||||
expect(middlewareCard.innerHTML).toContain('amount')
|
||||
expect(middlewareCard.innerHTML).toContain('10')
|
||||
expect(middlewareCard.innerHTML).toContain('ipWhiteList')
|
||||
expect(middlewareCard.innerHTML).toContain('source Range')
|
||||
expect(middlewareCard.innerHTML).toContain('125.0.0.1')
|
||||
expect(middlewareCard.innerHTML).toContain('125.0.0.4')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test-complex@docker')
|
||||
})
|
||||
})
|
||||
73
webui/src/pages/tcp/TcpMiddleware.tsx
Normal file
73
webui/src/pages/tcp/TcpMiddleware.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Card, Box, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
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'
|
||||
|
||||
const MiddlewareGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))',
|
||||
|
||||
[`@media (max-width: ${breakpoints.tablet})`]: {
|
||||
gridTemplateColumns: '1fr',
|
||||
},
|
||||
})
|
||||
|
||||
type TcpMiddlewareRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpMiddlewareRender = ({ data, error, name }: TcpMiddlewareRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Middleware right now. Please, try again later.
|
||||
</Text>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$4' }} data-testid="skeleton" />
|
||||
<MiddlewareGrid>
|
||||
<DetailSectionSkeleton />
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSkeleton />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<MiddlewareGrid>
|
||||
<Card css={{ padding: '$5' }} data-testid="middleware-card">
|
||||
<RenderMiddleware middleware={data} />
|
||||
</Card>
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="tcp" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpMiddleware = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'middlewares', 'tcp')
|
||||
return <TcpMiddlewareRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default TcpMiddleware
|
||||
67
webui/src/pages/tcp/TcpMiddlewares.spec.tsx
Normal file
67
webui/src/pages/tcp/TcpMiddlewares.spec.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { makeRowRender, TcpMiddlewares as TcpMiddlewaresPage, TcpMiddlewaresRender } from './TcpMiddlewares'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpMiddlewaresPage />', () => {
|
||||
it('should render the middlewares list', () => {
|
||||
const pages = [
|
||||
{
|
||||
inFlightConn: { amount: 10 },
|
||||
status: 'enabled',
|
||||
usedBy: ['web@docker'],
|
||||
name: 'inFlightConn-foo@docker',
|
||||
provider: 'docker',
|
||||
type: 'inFlightConn',
|
||||
},
|
||||
{
|
||||
ipWhiteList: { sourceRange: ['125.0.0.1', '125.0.0.4'] },
|
||||
error: ['message 1', 'message 2'],
|
||||
status: 'disabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'ipWhiteList@docker',
|
||||
provider: 'docker',
|
||||
type: 'ipWhiteList',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<TcpMiddlewaresPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('inFlightConn-foo@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('inFlightConn')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="disabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('ipWhiteList@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('ipWhiteList')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<TcpMiddlewaresRender
|
||||
error={undefined}
|
||||
isEmpty={true}
|
||||
isLoadingMore={false}
|
||||
isReachingEnd={true}
|
||||
loadMore={() => {}}
|
||||
pageCount={1}
|
||||
pages={[]}
|
||||
/>,
|
||||
)
|
||||
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1)
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available')
|
||||
})
|
||||
})
|
||||
125
webui/src/pages/tcp/TcpMiddlewares.tsx
Normal file
125
webui/src/pages/tcp/TcpMiddlewares.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
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 => {
|
||||
const TcpMiddlewaresRenderRow = (row) => {
|
||||
const middlewareType = parseMiddlewareType(row)
|
||||
|
||||
return (
|
||||
<ClickableRow key={row.name} to={`/tcp/middlewares/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={middlewareType} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
}
|
||||
return TcpMiddlewaresRenderRow
|
||||
}
|
||||
|
||||
export const TcpMiddlewaresRender = ({
|
||||
error,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
loadMore,
|
||||
pageCount,
|
||||
pages,
|
||||
}: pagesResponseInterface) => {
|
||||
const [infiniteRef] = useInfiniteScroll({
|
||||
loading: isLoadingMore,
|
||||
hasNextPage: !isReachingEnd && !error,
|
||||
onLoadMore: loadMore,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>{pages}</AriaTbody>
|
||||
{(isEmpty || !!error) && (
|
||||
<AriaTfoot>
|
||||
<AriaTr>
|
||||
<AriaTd fullColSpan>
|
||||
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
</AriaTfoot>
|
||||
)}
|
||||
</AriaTable>
|
||||
<Flex css={{ height: 60, alignItems: 'center', justifyContent: 'center' }} ref={infiniteRef}>
|
||||
{isLoadingMore ? <SpinnerLoader /> : isReachingEnd && pageCount > 1 && <ScrollTopButton />}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpMiddlewares = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/tcp/middlewares',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="TCP Middlewares">
|
||||
<TableFilter />
|
||||
<TcpMiddlewaresRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
102
webui/src/pages/tcp/TcpRouter.spec.tsx
Normal file
102
webui/src/pages/tcp/TcpRouter.spec.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { TcpRouterRender } from './TcpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the router details', async () => {
|
||||
const mockData = {
|
||||
entryPoints: ['web-tcp'],
|
||||
service: 'tcp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'tcp-all@docker',
|
||||
provider: 'docker',
|
||||
middlewares: [
|
||||
{
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware00@docker',
|
||||
provider: 'docker',
|
||||
type: 'middleware00',
|
||||
},
|
||||
{
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware01@docker',
|
||||
provider: 'docker',
|
||||
type: 'middleware01',
|
||||
},
|
||||
],
|
||||
hasValidMiddlewares: true,
|
||||
entryPointsData: [
|
||||
{
|
||||
address: ':8000',
|
||||
name: 'web',
|
||||
},
|
||||
{
|
||||
address: ':443',
|
||||
name: 'web-secured',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const routerStructure = getByTestId('router-structure')
|
||||
expect(routerStructure.innerHTML).toContain(':443')
|
||||
expect(routerStructure.innerHTML).toContain(':8000')
|
||||
expect(routerStructure.innerHTML).toContain('tcp-all@docker')
|
||||
expect(routerStructure.innerHTML).toContain('tcp-all</span>')
|
||||
expect(routerStructure.innerHTML).toContain('TCP Router')
|
||||
expect(routerStructure.innerHTML).not.toContain('HTTP Router')
|
||||
|
||||
const routerDetailsSection = getByTestId('router-details')
|
||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
||||
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Status')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Success')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Provider')
|
||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Name')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('tcp-all@docker')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web</')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web-secured')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('tcp-all</')
|
||||
|
||||
const middlewaresPanel = routerDetailsSection.querySelector(':scope > div:nth-child(3)')
|
||||
const providers = Array.from(middlewaresPanel?.querySelectorAll('svg[data-testid="docker"]') || [])
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware00')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware01')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('Success')
|
||||
expect(providers.length).toBe(2)
|
||||
|
||||
expect(getByTestId('/tcp/services/tcp-all@docker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
82
webui/src/pages/tcp/TcpRouter.tsx
Normal file
82
webui/src/pages/tcp/TcpRouter.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { CardListSection, DetailSectionSkeleton } from 'components/resources/DetailSections'
|
||||
import MiddlewarePanel from 'components/resources/MiddlewarePanel'
|
||||
import RouterPanel from 'components/resources/RouterPanel'
|
||||
import TlsPanel from 'components/resources/TlsPanel'
|
||||
import { ResourceDetailDataType, useResourceDetail } from 'hooks/use-resource-detail'
|
||||
import Page from 'layout/Page'
|
||||
import { RouterStructure } from 'pages/http/HttpRouter'
|
||||
import { NotFound } from 'pages/NotFound'
|
||||
|
||||
type DetailProps = {
|
||||
data: ResourceDetailDataType
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const RouterDetail = ({ data }: DetailProps) => (
|
||||
<SpacedColumns data-testid="router-details">
|
||||
<RouterPanel data={data} />
|
||||
<TlsPanel data={data} />
|
||||
<MiddlewarePanel data={data} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type TcpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpRouterRender = ({ data, error, name }: TcpRouterRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Router right now. Please, try again later.
|
||||
</Text>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Flex css={{ flexDirection: 'row', mb: '70px' }} data-testid="skeleton">
|
||||
<CardListSection bigDescription />
|
||||
<CardListSection />
|
||||
<CardListSection isLast />
|
||||
</Flex>
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
</SpacedColumns>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<RouterStructure data={data} protocol="tcp" />
|
||||
<RouterDetail data={data} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers', 'tcp')
|
||||
return <TcpRouterRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default TcpRouter
|
||||
85
webui/src/pages/tcp/TcpRouters.spec.tsx
Normal file
85
webui/src/pages/tcp/TcpRouters.spec.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { makeRowRender, TcpRouters as TcpRoutersPage, TcpRoutersRender } from './TcpRouters'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpRoutersPage />', () => {
|
||||
it('should render the routers list', () => {
|
||||
const pages = [
|
||||
{
|
||||
entryPoints: ['web-tcp'],
|
||||
service: 'tcp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'tcp-all@docker00',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-tcp'],
|
||||
service: 'tcp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'disabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'tcp-all@docker01',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-tcp'],
|
||||
service: 'tcp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'tcp-all@docker02',
|
||||
provider: 'docker',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<TcpRoutersPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('HostSNI(`*`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toIncludeMultiple(['web-tcp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('tcp-all@docker00')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="disabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('HostSNI(`*`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toIncludeMultiple(['web-tcp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('tcp-all@docker01')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('HostSNI(`*`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toIncludeMultiple(['web-tcp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('tcp-all@docker02')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<TcpRoutersRender
|
||||
error={undefined}
|
||||
isEmpty={true}
|
||||
isLoadingMore={false}
|
||||
isReachingEnd={true}
|
||||
loadMore={() => {}}
|
||||
pageCount={1}
|
||||
pages={[]}
|
||||
/>,
|
||||
)
|
||||
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1)
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available')
|
||||
})
|
||||
})
|
||||
142
webui/src/pages/tcp/TcpRouters.tsx
Normal file
142
webui/src/pages/tcp/TcpRouters.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { FiShield } from 'react-icons/fi'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { Chips } from 'components/resources/DetailSections'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
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) => (
|
||||
<ClickableRow key={row.name} to={`/tcp/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
{row.tls && (
|
||||
<Tooltip label="TLS ON">
|
||||
<Box css={{ width: 24, height: 24 }} data-testid="tls-on">
|
||||
<FiShield color="#008000" fill="#008000" size={24} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.rule} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>{row.entryPoints && row.entryPoints.length > 0 && <Chips items={row.entryPoints} />}</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.service} isTruncated />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.priority} isTruncated />
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
return TcpRoutersRenderRow
|
||||
}
|
||||
|
||||
export const TcpRoutersRender = ({
|
||||
error,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
loadMore,
|
||||
pageCount,
|
||||
pages,
|
||||
}: pagesResponseInterface) => {
|
||||
const [infiniteRef] = useInfiniteScroll({
|
||||
loading: isLoadingMore,
|
||||
hasNextPage: !isReachingEnd && !error,
|
||||
onLoadMore: loadMore,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="TLS" css={{ width: '40px' }} />
|
||||
<SortableTh label="Rule" isSortable sortByValue="rule" />
|
||||
<SortableTh label="Entrypoints" isSortable sortByValue="entryPoints" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Service" isSortable sortByValue="service" />
|
||||
<SortableTh label="Provider" isSortable sortByValue="provider" />
|
||||
<SortableTh label="Priority" isSortable sortByValue="priority" />
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>{pages}</AriaTbody>
|
||||
{(isEmpty || !!error) && (
|
||||
<AriaTfoot>
|
||||
<AriaTr>
|
||||
<AriaTd fullColSpan>
|
||||
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
</AriaTfoot>
|
||||
)}
|
||||
</AriaTable>
|
||||
<Flex css={{ height: 60, alignItems: 'center', justifyContent: 'center' }} ref={infiniteRef}>
|
||||
{isLoadingMore ? <SpinnerLoader /> : isReachingEnd && pageCount > 1 && <ScrollTopButton />}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpRouters = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/tcp/routers',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="TCP Routers">
|
||||
<TableFilter />
|
||||
<TcpRoutersRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
163
webui/src/pages/tcp/TcpService.spec.tsx
Normal file
163
webui/src/pages/tcp/TcpService.spec.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { TcpServiceRender } from './TcpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<TcpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the service', async () => {
|
||||
const mockData = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
address: 'http://10.0.1.12:80',
|
||||
},
|
||||
],
|
||||
passHostHeader: true,
|
||||
terminationDelay: 10,
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['router-test1@docker'],
|
||||
name: 'service-test1',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
routers: [
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['redirect@file'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'router-test1@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'service-test1')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const serviceDetails = getByTestId('service-details')
|
||||
expect(serviceDetails.innerHTML).toContain('Type')
|
||||
expect(serviceDetails.innerHTML).toContain('loadbalancer')
|
||||
expect(serviceDetails.innerHTML).toContain('Provider')
|
||||
expect(serviceDetails.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass Host Header')
|
||||
expect(serviceDetails.innerHTML).toContain('True')
|
||||
expect(serviceDetails.innerHTML).toContain('Termination Delay')
|
||||
expect(serviceDetails.innerHTML).toContain('10 ms')
|
||||
|
||||
const serversList = getByTestId('servers-list')
|
||||
expect(serversList.childNodes.length).toBe(1)
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:80')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(1)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
})
|
||||
|
||||
it('should render the service servers from the serverStatus property', async () => {
|
||||
const mockData = {
|
||||
loadBalancer: {
|
||||
terminationDelay: 10,
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['router-test1@docker', 'router-test2@docker'],
|
||||
serverStatus: {
|
||||
'http://10.0.1.12:81': 'UP',
|
||||
},
|
||||
name: 'service-test2',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
routers: [
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['redirect@file'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'router-test1@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-secured'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-secured'],
|
||||
name: 'router-test2@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const serversList = getByTestId('servers-list')
|
||||
expect(serversList.childNodes.length).toBe(1)
|
||||
expect(serversList.innerHTML).toContain('http://10.0.1.12:81')
|
||||
|
||||
const routersTable = getByTestId('routers-table')
|
||||
const tableBody = routersTable.querySelectorAll('div[role="rowgroup"]')[1]
|
||||
expect(tableBody?.querySelectorAll('a[role="row"]')).toHaveLength(2)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
||||
})
|
||||
|
||||
it('should not render used by routers table if the usedBy property is empty', async () => {
|
||||
const mockData = {
|
||||
status: 'enabled',
|
||||
usedBy: [],
|
||||
name: 'service-test3',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
routers: [],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<TcpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
expect(() => {
|
||||
getByTestId('routers-table')
|
||||
}).toThrow('Unable to find an element by: [data-testid="routers-table"]')
|
||||
})
|
||||
})
|
||||
66
webui/src/pages/tcp/TcpService.tsx
Normal file
66
webui/src/pages/tcp/TcpService.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
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'
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
type TcpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const TcpServiceRender = ({ data, error, name }: TcpServiceRenderProps) => {
|
||||
if (error) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Text data-testid="error-text">
|
||||
Sorry, we could not fetch detail information for this Service right now. Please, try again later.
|
||||
</Text>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Page title={name}>
|
||||
<Skeleton css={{ height: '$7', width: '320px', mb: '$8' }} data-testid="skeleton" />
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton narrow />
|
||||
<DetailSectionSkeleton narrow />
|
||||
</SpacedColumns>
|
||||
<UsedByRoutersSkeleton />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<ServicePanels data={data} />
|
||||
<UsedByRoutersSection data={data} protocol="tcp" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services', 'tcp')
|
||||
return <TcpServiceRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default TcpService
|
||||
82
webui/src/pages/tcp/TcpServices.spec.tsx
Normal file
82
webui/src/pages/tcp/TcpServices.spec.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { makeRowRender, TcpServices as TcpServicesPage, TcpServicesRender } from './TcpServices'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<TcpServicesPage />', () => {
|
||||
it('should render the services list', () => {
|
||||
const pages = [
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'enabled',
|
||||
usedBy: ['tcp-all@docker'],
|
||||
name: 'tcp-all@docker00',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'disabled',
|
||||
usedBy: ['tcp-all@docker'],
|
||||
name: 'tcp-all@docker01',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'enabled',
|
||||
usedBy: ['tcp-all@docker'],
|
||||
name: 'tcp-all@docker02',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<TcpServicesPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('tcp-all@docker00')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('loadbalancer')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('1')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('testid="disabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('tcp-all@docker01')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('loadbalancer')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('1')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('tcp-all@docker02')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('loadbalancer')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('1')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<TcpServicesRender
|
||||
error={undefined}
|
||||
isEmpty={true}
|
||||
isLoadingMore={false}
|
||||
isReachingEnd={true}
|
||||
loadMore={() => {}}
|
||||
pageCount={1}
|
||||
pages={[]}
|
||||
/>,
|
||||
)
|
||||
expect(() => getByTestId('loading')).toThrow('Unable to find an element by: [data-testid="loading"]')
|
||||
const tfoot = container.querySelectorAll('div[role="table"] > div[role="rowgroup"]')[2]
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')).toHaveLength(1)
|
||||
expect(tfoot.querySelectorAll('div[role="row"]')[0].innerHTML).toContain('No data available')
|
||||
})
|
||||
})
|
||||
124
webui/src/pages/tcp/TcpServices.tsx
Normal file
124
webui/src/pages/tcp/TcpServices.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { AriaTable, AriaTbody, AriaTd, AriaTfoot, AriaThead, AriaTr, Box, Flex, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import useInfiniteScroll from 'react-infinite-scroll-hook'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ClickableRow from 'components/ClickableRow'
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import { ResourceStatus } from 'components/resources/ResourceStatus'
|
||||
import { ScrollTopButton } from 'components/ScrollTopButton'
|
||||
import { SpinnerLoader } from 'components/SpinnerLoader'
|
||||
import { searchParamsToState, TableFilter } from 'components/TableFilter'
|
||||
import SortableTh from 'components/tables/SortableTh'
|
||||
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) => (
|
||||
<ClickableRow key={row.name} to={`/tcp/services/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.name} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<TooltipText text={row.type} />
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Text>{row.loadBalancer?.servers?.length || 0}</Text>
|
||||
</AriaTd>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.provider}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ProviderIcon name={row.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
</ClickableRow>
|
||||
)
|
||||
return TcpServicesRenderRow
|
||||
}
|
||||
|
||||
export const TcpServicesRender = ({
|
||||
error,
|
||||
isEmpty,
|
||||
isLoadingMore,
|
||||
isReachingEnd,
|
||||
loadMore,
|
||||
pageCount,
|
||||
pages,
|
||||
}: pagesResponseInterface) => {
|
||||
const [infiniteRef] = useInfiniteScroll({
|
||||
loading: isLoadingMore,
|
||||
hasNextPage: !isReachingEnd && !error,
|
||||
onLoadMore: loadMore,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<AriaTable>
|
||||
<AriaThead>
|
||||
<AriaTr>
|
||||
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
|
||||
<SortableTh label="Name" isSortable sortByValue="name" />
|
||||
<SortableTh label="Type" isSortable sortByValue="type" />
|
||||
<SortableTh label="Servers" isSortable sortByValue="servers" />
|
||||
<SortableTh label="Provider" css={{ width: '75px' }} isSortable sortByValue="provider" />
|
||||
</AriaTr>
|
||||
</AriaThead>
|
||||
<AriaTbody>{pages}</AriaTbody>
|
||||
{(isEmpty || !!error) && (
|
||||
<AriaTfoot>
|
||||
<AriaTr>
|
||||
<AriaTd fullColSpan>
|
||||
<EmptyPlaceholder message={error ? 'Failed to fetch data' : 'No data available'} />
|
||||
</AriaTd>
|
||||
</AriaTr>
|
||||
</AriaTfoot>
|
||||
)}
|
||||
</AriaTable>
|
||||
<Flex css={{ height: 60, alignItems: 'center', justifyContent: 'center' }} ref={infiniteRef}>
|
||||
{isLoadingMore ? <SpinnerLoader /> : isReachingEnd && pageCount > 1 && <ScrollTopButton />}
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const TcpServices = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/tcp/services',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="TCP Services">
|
||||
<TableFilter />
|
||||
<TcpServicesRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
6
webui/src/pages/tcp/index.ts
Normal file
6
webui/src/pages/tcp/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { TcpMiddleware } from './TcpMiddleware'
|
||||
export { TcpMiddlewares } from './TcpMiddlewares'
|
||||
export { TcpRouter } from './TcpRouter'
|
||||
export { TcpRouters } from './TcpRouters'
|
||||
export { TcpService } from './TcpService'
|
||||
export { TcpServices } from './TcpServices'
|
||||
Loading…
Add table
Add a link
Reference in a new issue