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
24
webui/src/pages/NotFound.tsx
Normal file
24
webui/src/pages/NotFound.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { Box, Button, Flex, H1, Text } from '@traefiklabs/faency'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import Page from 'layout/Page'
|
||||
|
||||
export const NotFound = () => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Page title="Not found">
|
||||
<Flex css={{ flexDirection: 'column', alignItems: 'center', p: '$6' }}>
|
||||
<Box>
|
||||
<H1 style={{ fontSize: '80px', lineHeight: '120px' }}>404</H1>
|
||||
</Box>
|
||||
<Box css={{ pb: '$3' }}>
|
||||
<Text size={6}>I'm sorry, nothing around here...</Text>
|
||||
</Box>
|
||||
<Button variant="primary" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Button>
|
||||
</Flex>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<template>
|
||||
<div class="fixed-center text-center q-pa-md">
|
||||
<h1 class="q-ma-md">
|
||||
<strong>404</strong>
|
||||
</h1>
|
||||
<h5 class="q-ma-md">
|
||||
I'm sorry, nothing around here ...
|
||||
</h5>
|
||||
<q-btn
|
||||
color="secondary"
|
||||
style="width:200px;"
|
||||
@click="$router.push('/')"
|
||||
>
|
||||
Go back
|
||||
</q-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Error404'
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
<template>
|
||||
<page-default>
|
||||
<section
|
||||
v-if="!loading"
|
||||
class="app-section"
|
||||
>
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-xl q-pb-sm">
|
||||
<div
|
||||
v-if="middlewareByName.item"
|
||||
class="row no-wrap items-center app-title"
|
||||
>
|
||||
<div
|
||||
class="app-title-label"
|
||||
style="font-size: 26px"
|
||||
>
|
||||
{{ middlewareByName.item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-sm q-pb-lg">
|
||||
<div
|
||||
v-if="!loading"
|
||||
class="row items-start q-col-gutter-md"
|
||||
>
|
||||
<div
|
||||
v-if="middlewareByName.item"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-middlewares
|
||||
dense
|
||||
:data="[middlewareByName.item]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-start q-mt-xl"
|
||||
>
|
||||
<div class="col-12">
|
||||
<p
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="!loading && allRouters.length"
|
||||
class="app-section"
|
||||
>
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<div class="app-title-label">
|
||||
Used by Routers
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<main-table
|
||||
v-bind="getTableProps({ type: `${protocol}-routers` })"
|
||||
v-model:current-sort="sortBy"
|
||||
v-model:current-sort-dir="sortDir"
|
||||
:data="allRouters"
|
||||
:on-load-more="onGetAll"
|
||||
:request="()=>{}"
|
||||
:loading="routersLoading"
|
||||
:filter="routersFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import GetTablePropsMixin from '../../_mixins/GetTableProps'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import SkeletonBox from '../../components/_commons/SkeletonBox.vue'
|
||||
import PanelMiddlewares from '../../components/_commons/PanelMiddlewares.vue'
|
||||
import MainTable from '../../components/_commons/MainTable.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageMiddlewareDetail',
|
||||
components: {
|
||||
PageDefault,
|
||||
SkeletonBox,
|
||||
PanelMiddlewares,
|
||||
MainTable
|
||||
},
|
||||
mixins: [GetTablePropsMixin],
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
timeOutGetAll: null,
|
||||
allRouters: [],
|
||||
routersLoading: true,
|
||||
routersFilter: '',
|
||||
routersStatus: '',
|
||||
routersPagination: {
|
||||
sortBy: '',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 1000,
|
||||
rowsNumber: 0
|
||||
},
|
||||
filter: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('http', { http_middlewareByName: 'middlewareByName' }),
|
||||
...mapGetters('tcp', { tcp_middlewareByName: 'middlewareByName' }),
|
||||
protocol () {
|
||||
return this.$route.meta.protocol
|
||||
},
|
||||
middlewareByName () {
|
||||
return this[`${this.protocol}_middlewareByName`]
|
||||
},
|
||||
getMiddlewareByName () {
|
||||
return this[`${this.protocol}_getMiddlewareByName`]
|
||||
},
|
||||
getRouterByName () {
|
||||
return this[`${this.protocol}_getRouterByName`]
|
||||
},
|
||||
getAllRouters () {
|
||||
return this[`${this.protocol}_getAllRouters`]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.refreshAll()
|
||||
},
|
||||
mounted () {},
|
||||
beforeUnmount () {
|
||||
clearInterval(this.timeOutGetAll)
|
||||
this.$store.commit('http/getMiddlewareByNameClear')
|
||||
this.$store.commit('tcp/getMiddlewareByNameClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { http_getMiddlewareByName: 'getMiddlewareByName', http_getRouterByName: 'getRouterByName', http_getAllRouters: 'getAllRouters' }),
|
||||
...mapActions('tcp', { tcp_getMiddlewareByName: 'getMiddlewareByName', tcp_getRouterByName: 'getRouterByName', tcp_getAllRouters: 'getAllRouters' }),
|
||||
refreshAll () {
|
||||
if (this.middlewareByName.loading) {
|
||||
return
|
||||
}
|
||||
this.onGetAll()
|
||||
},
|
||||
onGetAll () {
|
||||
this.getMiddlewareByName(this.name)
|
||||
.then(body => {
|
||||
if (!body) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
// Get routers
|
||||
this.getAllRouters({
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
middlewareName: this.name,
|
||||
serviceName: '',
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir
|
||||
})
|
||||
.then(body => {
|
||||
this.allRouters = []
|
||||
if (body) {
|
||||
this.routersLoading = false
|
||||
this.allRouters.push(...body.data)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> routers/byName', error)
|
||||
})
|
||||
clearTimeout(this.timeOutGetAll)
|
||||
this.timeOutGetAll = setTimeout(() => {
|
||||
this.loading = false
|
||||
}, 300)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> middleware/byName', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../css/sass/variables";
|
||||
|
||||
</style>
|
||||
|
|
@ -1,428 +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
|
||||
v-if="!loading"
|
||||
class="row items-start"
|
||||
>
|
||||
<div
|
||||
v-if="entryPoints.length"
|
||||
class="col-12 col-md-3 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-log-in-outline" />
|
||||
<div class="app-title-label">
|
||||
Entrypoints
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div
|
||||
v-for="(entryPoint, index) in entryPoints"
|
||||
:key="index"
|
||||
class="col-12"
|
||||
>
|
||||
<panel-entry
|
||||
type="detail"
|
||||
ex-size="true"
|
||||
:name="entryPoint.name"
|
||||
:address="entryPoint.address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 xs-hide sm-hide">
|
||||
<q-icon
|
||||
name="eva-arrow-forward-outline"
|
||||
class="arrow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="routerByName.item.name"
|
||||
class="col-12 col-md-3 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-outline" />
|
||||
<div class="app-title-label">
|
||||
{{ routerType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-entry
|
||||
focus="true"
|
||||
type="detail"
|
||||
name="router"
|
||||
:address="routerByName.item.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 xs-hide sm-hide">
|
||||
<q-icon
|
||||
name="eva-arrow-forward-outline"
|
||||
class="arrow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="hasMiddlewares"
|
||||
class="col-12 col-md-3 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-layers" />
|
||||
<div class="app-title-label">
|
||||
{{ middlewareType }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div
|
||||
v-for="(middleware, index) in middlewares"
|
||||
:key="index"
|
||||
class="col-12"
|
||||
>
|
||||
<panel-entry
|
||||
type="detail"
|
||||
name="Middleware"
|
||||
:address="middleware.type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 xs-hide sm-hide">
|
||||
<q-icon
|
||||
name="eva-arrow-forward-outline"
|
||||
class="arrow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="routerByName.item.service"
|
||||
class="service col-12 col-md-3 q-mb-lg path-block"
|
||||
@click="$router.push({ path: `/${protocol}/services/${getServiceId(routerByName.item)}`})"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-flash" />
|
||||
<div class="app-title-label">
|
||||
Service
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-entry
|
||||
type="detail"
|
||||
name="Service"
|
||||
:address="routerByName.item.service"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-start"
|
||||
>
|
||||
<div class="col-12">
|
||||
<p
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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
|
||||
v-if="!loading"
|
||||
class="row items-start q-col-gutter-md"
|
||||
>
|
||||
<div
|
||||
v-if="routerByName.item"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-info" />
|
||||
<div class="app-title-label">
|
||||
Router Details
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-router-details
|
||||
:data="routerByName.item"
|
||||
:protocol="protocol"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="protocol !== 'udp'"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-shield" />
|
||||
<div class="app-title-label">
|
||||
TLS
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-t-l-s
|
||||
:data="routerByName.item.tls"
|
||||
:protocol="protocol"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="protocol !== 'udp'"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-layers" />
|
||||
<div class="app-title-label">
|
||||
Middlewares
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-middlewares :data="middlewares" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-start"
|
||||
>
|
||||
<div class="col-12">
|
||||
<p
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import SkeletonBox from '../../components/_commons/SkeletonBox.vue'
|
||||
import PanelEntry from '../../components/dashboard/PanelEntry.vue'
|
||||
import PanelRouterDetails from '../../components/_commons/PanelRouterDetails.vue'
|
||||
import PanelTLS from '../../components/_commons/PanelTLS.vue'
|
||||
import PanelMiddlewares from '../../components/_commons/PanelMiddlewares.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageRouterDetail',
|
||||
components: {
|
||||
PageDefault,
|
||||
SkeletonBox,
|
||||
PanelEntry,
|
||||
PanelRouterDetails,
|
||||
PanelTLS,
|
||||
PanelMiddlewares
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
entryPoints: [],
|
||||
middlewares: [],
|
||||
timeOutGetAll: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasTLSConfiguration () {
|
||||
return this.routerByName.item.tls
|
||||
},
|
||||
middlewareType () {
|
||||
return this.$route.meta.protocol.toUpperCase() + ' Middlewares'
|
||||
},
|
||||
routerType () {
|
||||
return this.$route.meta.protocol.toUpperCase() + ' Router'
|
||||
},
|
||||
...mapGetters('http', { http_routerByName: 'routerByName' }),
|
||||
...mapGetters('tcp', { tcp_routerByName: 'routerByName' }),
|
||||
...mapGetters('udp', { udp_routerByName: 'routerByName' }),
|
||||
hasMiddlewares () {
|
||||
return this.$route.meta.protocol !== 'udp' && this.middlewares.length > 0
|
||||
},
|
||||
protocol () {
|
||||
return this.$route.meta.protocol
|
||||
},
|
||||
routerByName () {
|
||||
return this[`${this.protocol}_routerByName`]
|
||||
},
|
||||
getRouterByName () {
|
||||
return this[`${this.protocol}_getRouterByName`]
|
||||
},
|
||||
getMiddlewareByName () {
|
||||
return this[`${this.protocol}_getMiddlewareByName`]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.refreshAll()
|
||||
},
|
||||
beforeUnmount () {
|
||||
clearInterval(this.timeOutGetAll)
|
||||
this.$store.commit('http/getRouterByNameClear')
|
||||
this.$store.commit('tcp/getRouterByNameClear')
|
||||
this.$store.commit('udp/getRouterByNameClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { http_getRouterByName: 'getRouterByName', http_getMiddlewareByName: 'getMiddlewareByName' }),
|
||||
...mapActions('tcp', { tcp_getRouterByName: 'getRouterByName', tcp_getMiddlewareByName: 'getMiddlewareByName' }),
|
||||
...mapActions('udp', { udp_getRouterByName: 'getRouterByName' }),
|
||||
...mapActions('entrypoints', { getEntrypointsByName: 'getByName' }),
|
||||
refreshAll () {
|
||||
if (this.routerByName.loading) {
|
||||
return
|
||||
}
|
||||
this.onGetAll()
|
||||
},
|
||||
onGetAll () {
|
||||
this.getRouterByName(this.name)
|
||||
.then(body => {
|
||||
if (!body) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
// Get entryPoints
|
||||
if (body.using) {
|
||||
for (const entryPoint in body.using) {
|
||||
if (Object.getOwnPropertyDescriptor(body.using, entryPoint)) {
|
||||
this.getEntrypointsByName(body.using[entryPoint])
|
||||
.then(body => {
|
||||
if (body) {
|
||||
this.entryPoints.push(body)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> entrypoints/byName', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Get middlewares
|
||||
if (body.middlewares) {
|
||||
for (const middleware in body.middlewares) {
|
||||
if (Object.getOwnPropertyDescriptor(body.middlewares, middleware)) {
|
||||
this.getMiddlewareByName(body.middlewares[middleware])
|
||||
.then(body => {
|
||||
if (body) {
|
||||
this.middlewares.push(body)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> middlewares/byName', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
clearTimeout(this.timeOutGetAll)
|
||||
this.timeOutGetAll = setTimeout(() => {
|
||||
this.loading = false
|
||||
}, 300)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> routers/byName', error)
|
||||
})
|
||||
},
|
||||
getServiceId (data) {
|
||||
const words = data.service.split('@')
|
||||
if (words.length === 2) {
|
||||
return data.service
|
||||
}
|
||||
|
||||
return `${encodeURIComponent(data.service)}@${data.provider}`
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../css/sass/variables";
|
||||
|
||||
.path-block {
|
||||
.arrow {
|
||||
font-size: 40px;
|
||||
margin-top: 20px;
|
||||
margin-left: 20px;
|
||||
color: #b2b2b2;
|
||||
}
|
||||
|
||||
&.service {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
<template>
|
||||
<page-default>
|
||||
<section
|
||||
v-if="!loading"
|
||||
class="app-section"
|
||||
>
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-xl q-pb-lg">
|
||||
<div
|
||||
v-if="serviceByName.item"
|
||||
class="row no-wrap items-center app-title"
|
||||
>
|
||||
<div
|
||||
class="app-title-label"
|
||||
style="font-size: 26px"
|
||||
>
|
||||
{{ serviceByName.item.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-lg">
|
||||
<div
|
||||
v-if="!loading"
|
||||
class="row items-start q-col-gutter-md"
|
||||
>
|
||||
<div
|
||||
v-if="serviceByName.item"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-info" />
|
||||
<div class="app-title-label">
|
||||
Service Details
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-service-details
|
||||
dense
|
||||
:data="serviceByName.item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serviceByName.item.loadBalancer && serviceByName.item.loadBalancer.healthCheck"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-shield" />
|
||||
<div class="app-title-label">
|
||||
Health Check
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-health-check
|
||||
dense
|
||||
:data="serviceByName.item.loadBalancer.healthCheck"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serviceByName.item.loadBalancer"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-outline" />
|
||||
<div class="app-title-label">
|
||||
Servers
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-servers
|
||||
dense
|
||||
:data="serviceByName.item"
|
||||
:has-status="serviceByName.item.serverStatus"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serviceByName.item.weighted && serviceByName.item.weighted.services"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-outline" />
|
||||
<div class="app-title-label">
|
||||
Services
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-weighted-services
|
||||
dense
|
||||
:data="serviceByName.item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="serviceByName.item.mirroring && serviceByName.item.mirroring.mirrors"
|
||||
class="col-12 col-md-4 q-mb-lg path-block"
|
||||
>
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-outline" />
|
||||
<div class="app-title-label">
|
||||
Mirror Services
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-start q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<div class="row items-start q-col-gutter-md">
|
||||
<div class="col-12">
|
||||
<panel-mirroring-services
|
||||
dense
|
||||
:data="serviceByName.item"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-start q-mt-xl"
|
||||
>
|
||||
<div class="col-12">
|
||||
<p
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="!loading && allRouters.length"
|
||||
class="app-section"
|
||||
>
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<div class="app-title-label">
|
||||
Used by Routers
|
||||
</div>
|
||||
</div>
|
||||
<div class="row items-center q-col-gutter-lg">
|
||||
<div class="col-12">
|
||||
<main-table
|
||||
v-bind="getTableProps({ type: `${protocol}-routers` })"
|
||||
v-model:current-sort="sortBy"
|
||||
v-model:current-sort-dir="sortDir"
|
||||
:data="allRouters"
|
||||
:on-load-more="onGetAll"
|
||||
:request="()=>{}"
|
||||
:loading="routersLoading"
|
||||
:filter="routersFilter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import GetTablePropsMixin from '../../_mixins/GetTableProps'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import SkeletonBox from '../../components/_commons/SkeletonBox.vue'
|
||||
import PanelServiceDetails from '../../components/_commons/PanelServiceDetails.vue'
|
||||
import PanelHealthCheck from '../../components/_commons/PanelHealthCheck.vue'
|
||||
import PanelServers from '../../components/_commons/PanelServers.vue'
|
||||
import MainTable from '../../components/_commons/MainTable.vue'
|
||||
import PanelWeightedServices from '../../components/_commons/PanelWeightedServices.vue'
|
||||
import PanelMirroringServices from '../../components/_commons/PanelMirroringServices.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageServiceDetail',
|
||||
components: {
|
||||
PanelMirroringServices,
|
||||
PanelWeightedServices,
|
||||
PageDefault,
|
||||
SkeletonBox,
|
||||
PanelServiceDetails,
|
||||
PanelHealthCheck,
|
||||
PanelServers,
|
||||
MainTable
|
||||
},
|
||||
mixins: [GetTablePropsMixin],
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loading: true,
|
||||
timeOutGetAll: null,
|
||||
allRouters: [],
|
||||
routersLoading: true,
|
||||
routersFilter: '',
|
||||
routersStatus: '',
|
||||
routersPagination: {
|
||||
sortBy: '',
|
||||
descending: true,
|
||||
page: 1,
|
||||
rowsPerPage: 1000,
|
||||
rowsNumber: 0
|
||||
},
|
||||
filter: '',
|
||||
status: '',
|
||||
sortBy: 'name',
|
||||
sortDir: 'asc'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('http', { http_serviceByName: 'serviceByName' }),
|
||||
...mapGetters('tcp', { tcp_serviceByName: 'serviceByName' }),
|
||||
...mapGetters('udp', { udp_serviceByName: 'serviceByName' }),
|
||||
protocol () {
|
||||
return this.$route.meta.protocol
|
||||
},
|
||||
serviceByName () {
|
||||
return this[`${this.protocol}_serviceByName`]
|
||||
},
|
||||
getServiceByName () {
|
||||
return this[`${this.protocol}_getServiceByName`]
|
||||
},
|
||||
getRouterByName () {
|
||||
return this[`${this.protocol}_getRouterByName`]
|
||||
},
|
||||
getAllRouters () {
|
||||
return this[`${this.protocol}_getAllRouters`]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.refreshAll()
|
||||
},
|
||||
mounted () {},
|
||||
beforeUnmount () {
|
||||
clearInterval(this.timeOutGetAll)
|
||||
this.$store.commit('http/getServiceByNameClear')
|
||||
this.$store.commit('tcp/getServiceByNameClear')
|
||||
this.$store.commit('udp/getServiceByNameClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { http_getServiceByName: 'getServiceByName', http_getRouterByName: 'getRouterByName', http_getAllRouters: 'getAllRouters' }),
|
||||
...mapActions('tcp', { tcp_getServiceByName: 'getServiceByName', tcp_getRouterByName: 'getRouterByName', tcp_getAllRouters: 'getAllRouters' }),
|
||||
...mapActions('udp', { udp_getServiceByName: 'getServiceByName', udp_getRouterByName: 'getRouterByName', udp_getAllRouters: 'getAllRouters' }),
|
||||
refreshAll () {
|
||||
if (this.serviceByName.loading) {
|
||||
return
|
||||
}
|
||||
this.onGetAll()
|
||||
},
|
||||
onGetAll () {
|
||||
this.getServiceByName(this.name)
|
||||
.then(body => {
|
||||
if (!body) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
// Get routers
|
||||
this.getAllRouters({
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
middlewareName: '',
|
||||
serviceName: this.name,
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir
|
||||
})
|
||||
.then(body => {
|
||||
this.allRouters = []
|
||||
if (body) {
|
||||
this.routersLoading = false
|
||||
this.allRouters.push(...body.data)
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> getAllRouters', error)
|
||||
})
|
||||
clearTimeout(this.timeOutGetAll)
|
||||
this.timeOutGetAll = setTimeout(() => {
|
||||
this.loading = false
|
||||
}, 300)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> service/byName', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../css/sass/variables";
|
||||
|
||||
</style>
|
||||
234
webui/src/pages/dashboard/Dashboard.tsx
Normal file
234
webui/src/pages/dashboard/Dashboard.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { Card, CSS, Flex, Grid, H2, Text } from '@traefiklabs/faency'
|
||||
import { ReactNode, useMemo } from 'react'
|
||||
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']
|
||||
|
||||
const SectionContainer = ({
|
||||
title,
|
||||
children,
|
||||
childrenContainerCss,
|
||||
css,
|
||||
}: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
childrenContainerCss?: CSS
|
||||
css?: CSS
|
||||
}) => {
|
||||
return (
|
||||
<Flex direction="column" gap={4} css={{ mt: '$4', ...css }}>
|
||||
<Flex align="center" gap={2} css={{ color: '$headingDefault', mb: '$4' }}>
|
||||
<H2 css={{ fontSize: '$8' }}>{title}</H2>
|
||||
</Flex>
|
||||
<Grid
|
||||
gap={6}
|
||||
css={{
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(215px, 1fr))',
|
||||
alignItems: 'stretch',
|
||||
...childrenContainerCss,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Grid>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
type ResourceData = {
|
||||
errors: number
|
||||
warnings: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const Dashboard = () => {
|
||||
const { data: entrypoints } = useSWR('/entrypoints')
|
||||
const { data: overview } = useSWR('/overview')
|
||||
|
||||
const features = useMemo(
|
||||
() =>
|
||||
overview?.features
|
||||
? Object.keys(overview?.features).map((key: string) => {
|
||||
return { name: key, value: overview.features[key] }
|
||||
})
|
||||
: [],
|
||||
[overview?.features],
|
||||
)
|
||||
|
||||
const hasResources = useMemo(() => {
|
||||
const filterFn = (x: ResourceData) => !x.errors && !x.total && !x.warnings
|
||||
return {
|
||||
http: Object.values<ResourceData>(overview?.http || {}).filter(filterFn).length !== 3,
|
||||
tcp: Object.values<ResourceData>(overview?.tcp || {}).filter(filterFn).length !== 3,
|
||||
udp: Object.values<ResourceData>(overview?.udp || {}).filter(filterFn).length !== 2,
|
||||
}
|
||||
}, [overview])
|
||||
|
||||
// @FIXME skeleton not correctly displayed if only using suspense
|
||||
if (!entrypoints || !overview) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Dashboard">
|
||||
<Flex direction="column" gap={6}>
|
||||
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
|
||||
{entrypoints?.map((i, idx) => (
|
||||
<ResourceCard
|
||||
key={`entrypoint-${i.name}-${idx}`}
|
||||
css={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
minHeight: '125px',
|
||||
}}
|
||||
title={i.name}
|
||||
titleCSS={{ textAlign: 'center' }}
|
||||
>
|
||||
<Text css={{ fontSize: '$11', fontWeight: 500, wordBreak: 'break-word' }}>{i.address}</Text>
|
||||
</ResourceCard>
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="HTTP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{overview?.http && hasResources.http ? (
|
||||
RESOURCES.map((i) => (
|
||||
<TraefikResourceStatsCard
|
||||
key={`http-${i}`}
|
||||
title={capitalizeFirstLetter(i)}
|
||||
data-testid={`section-http-${i}`}
|
||||
linkTo={`/http/${i}`}
|
||||
{...overview.http[i]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size={4}>No related objects to show.</Text>
|
||||
)}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="TCP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{overview?.tcp && hasResources.tcp ? (
|
||||
RESOURCES.map((i) => (
|
||||
<TraefikResourceStatsCard
|
||||
key={`tcp-${i}`}
|
||||
title={capitalizeFirstLetter(i)}
|
||||
data-testid={`section-tcp-${i}`}
|
||||
linkTo={`/tcp/${i}`}
|
||||
{...overview.tcp[i]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size={4}>No related objects to show.</Text>
|
||||
)}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="UDP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{overview?.udp && hasResources.udp ? (
|
||||
RESOURCES.map((i) => (
|
||||
<TraefikResourceStatsCard
|
||||
key={`udp-${i}`}
|
||||
title={capitalizeFirstLetter(i)}
|
||||
data-testid={`section-udp-${i}`}
|
||||
linkTo={`/udp/${i}`}
|
||||
{...overview.udp[i]}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text size={4}>No related objects to show.</Text>
|
||||
)}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer title="Features">
|
||||
{features.length
|
||||
? features.map((i, idx) => {
|
||||
return <FeatureCard key={`feature-${idx}`} feature={i} />
|
||||
})
|
||||
: null}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer title="Providers">
|
||||
{overview?.providers?.length ? (
|
||||
overview.providers.map((p, idx) => (
|
||||
<Card key={`provider-${idx}`} css={{ height: 125 }}>
|
||||
<Flex direction="column" align="center" gap={3} justify="center" css={{ height: '100%' }}>
|
||||
<ProviderIcon name={p} size={52} />
|
||||
<Text css={{ fontSize: '$4', fontWeight: 500, textAlign: 'center' }}>{p}</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<Text size={4}>No related objects to show.</Text>
|
||||
)}
|
||||
</SectionContainer>
|
||||
</Flex>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const DashboardSkeleton = () => {
|
||||
return (
|
||||
<Page>
|
||||
<Flex direction="column" gap={6}>
|
||||
<SectionContainer title="Entrypoints" css={{ mt: 0 }}>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<FeatureCardSkeleton key={`entry-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="HTTP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<StatsCardSkeleton key={`http-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="TCP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<StatsCardSkeleton key={`tcp-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer
|
||||
title="UDP"
|
||||
childrenContainerCss={{ gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))' }}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<StatsCardSkeleton key={`udp-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer title="Features">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<FeatureCardSkeleton key={`feature-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
|
||||
<SectionContainer title="Providers">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<FeatureCardSkeleton key={`provider-skeleton-${i}`} />
|
||||
))}
|
||||
</SectionContainer>
|
||||
</Flex>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,386 +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-lg">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-log-in-outline" />
|
||||
<div class="app-title-label">
|
||||
Entrypoints
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingEntryGetAll"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(entryItems, index) in entryAll.items"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-3"
|
||||
>
|
||||
<panel-entry
|
||||
:name="entryItems.name"
|
||||
:address="entryItems.address"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-2">
|
||||
<p
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-lg">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-outline" />
|
||||
<div class="app-title-label">
|
||||
HTTP
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingOverview"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(overviewHTTP, index) in allHTTP"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-4"
|
||||
>
|
||||
<panel-chart
|
||||
:name="index"
|
||||
:data="overviewHTTP"
|
||||
type="http"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<p
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-lg">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-3" />
|
||||
<div class="app-title-label">
|
||||
TCP
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingOverview"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(overviewTCP, index) in allTCP"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-4"
|
||||
>
|
||||
<panel-chart
|
||||
:name="index"
|
||||
:data="overviewTCP"
|
||||
type="tcp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<p
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-lg">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-globe-3" />
|
||||
<div class="app-title-label">
|
||||
UDP
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingOverview"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(overviewUDP, index) in allUDP"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-4"
|
||||
>
|
||||
<panel-chart
|
||||
:name="index"
|
||||
:data="overviewUDP"
|
||||
type="udp"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-4">
|
||||
<p
|
||||
v-for="n in 6"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-lg">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-toggle-right" />
|
||||
<div class="app-title-label">
|
||||
Features
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingOverview"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(overviewFeature, index) in allFeatures"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-2"
|
||||
>
|
||||
<panel-feature
|
||||
:feature-key="index"
|
||||
:feature-val="overviewFeature"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-2">
|
||||
<p
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="app-section">
|
||||
<div class="app-section-wrap app-boxed app-boxed-xl q-pl-md q-pr-md q-pt-lg q-pb-xl">
|
||||
<div class="row no-wrap items-center q-mb-lg app-title">
|
||||
<q-icon name="eva-cube" />
|
||||
<div class="app-title-label">
|
||||
Providers
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!loadingOverview"
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div
|
||||
v-for="(overviewProvider, index) in allProviders"
|
||||
:key="index"
|
||||
class="col-12 col-sm-6 col-md-2"
|
||||
>
|
||||
<panel-provider :name="overviewProvider" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="row items-center q-col-gutter-lg"
|
||||
>
|
||||
<div class="col-12 col-sm-6 col-md-2">
|
||||
<p
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="flex"
|
||||
>
|
||||
<SkeletonBox
|
||||
:min-width="15"
|
||||
:max-width="15"
|
||||
style="margin-right: 2%"
|
||||
/> <SkeletonBox
|
||||
:min-width="50"
|
||||
:max-width="83"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</page-default>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue'
|
||||
import { mapActions, mapGetters } from 'vuex'
|
||||
import PageDefault from '../../components/_commons/PageDefault.vue'
|
||||
import SkeletonBox from '../../components/_commons/SkeletonBox.vue'
|
||||
import PanelEntry from '../../components/dashboard/PanelEntry.vue'
|
||||
import PanelChart from '../../components/dashboard/PanelChart.vue'
|
||||
import PanelFeature from '../../components/dashboard/PanelFeature.vue'
|
||||
import PanelProvider from '../../components/dashboard/PanelProvider.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'PageDashboardIndex',
|
||||
components: {
|
||||
PageDefault,
|
||||
SkeletonBox,
|
||||
PanelEntry,
|
||||
PanelChart,
|
||||
PanelFeature,
|
||||
PanelProvider
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
loadingEntryGetAll: true,
|
||||
loadingOverview: true,
|
||||
timeOutEntryGetAll: null,
|
||||
timeOutOverviewAll: null,
|
||||
intervalRefresh: null,
|
||||
intervalRefreshTime: 5000
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('entrypoints', { entryAll: 'all' }),
|
||||
...mapGetters('core', { overviewAll: 'allOverview' }),
|
||||
allHTTP () {
|
||||
return this.overviewAll.items.http
|
||||
},
|
||||
allTCP () {
|
||||
return this.overviewAll.items.tcp
|
||||
},
|
||||
allUDP () {
|
||||
return this.overviewAll.items.udp
|
||||
},
|
||||
allFeatures () {
|
||||
return this.overviewAll.items.features
|
||||
},
|
||||
allProviders () {
|
||||
return this.overviewAll.items.providers
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchAll()
|
||||
this.intervalRefresh = setInterval(this.fetchOverview, this.intervalRefreshTime)
|
||||
},
|
||||
beforeUnmount () {
|
||||
clearInterval(this.intervalRefresh)
|
||||
clearTimeout(this.timeOutEntryGetAll)
|
||||
clearTimeout(this.timeOutOverviewAll)
|
||||
this.$store.commit('entrypoints/getAllClear')
|
||||
this.$store.commit('core/getOverviewClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('entrypoints', { entryGetAll: 'getAll' }),
|
||||
...mapActions('core', { getOverview: 'getOverview' }),
|
||||
fetchEntries () {
|
||||
this.entryGetAll()
|
||||
.then(body => {
|
||||
console.log('Success -> dashboard/entrypoints', body)
|
||||
clearTimeout(this.timeOutEntryGetAll)
|
||||
this.timeOutEntryGetAll = setTimeout(() => {
|
||||
this.loadingEntryGetAll = false
|
||||
}, 300)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> dashboard/entrypoints', error)
|
||||
})
|
||||
},
|
||||
fetchOverview () {
|
||||
this.getOverview()
|
||||
.then(body => {
|
||||
console.log('Success -> dashboard/overview', body)
|
||||
clearTimeout(this.timeOutOverviewAll)
|
||||
this.timeOutOverviewAll = setTimeout(() => {
|
||||
this.loadingOverview = false
|
||||
}, 300)
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Error -> dashboard/overview', error)
|
||||
})
|
||||
},
|
||||
fetchAll () {
|
||||
this.fetchEntries()
|
||||
this.fetchOverview()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
481
webui/src/pages/http/HttpMiddleware.spec.tsx
Normal file
481
webui/src/pages/http/HttpMiddleware.spec.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
import { HttpMiddlewareRender } from './HttpMiddleware'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpMiddlewarePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewareRender name="mock-middleware" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a simple middleware', () => {
|
||||
const mockMiddleware = {
|
||||
addPrefix: {
|
||||
prefix: '/foo',
|
||||
},
|
||||
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
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware 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.innerHTML).toContain('addprefix')
|
||||
expect(middlewareCard.querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('/foo')
|
||||
|
||||
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 plugin middleware', () => {
|
||||
const mockMiddleware = {
|
||||
plugin: {
|
||||
jwtAuth: {},
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['router-test-plugin@docker'],
|
||||
name: 'middleware-plugin',
|
||||
provider: 'docker',
|
||||
type: 'plugin',
|
||||
routers: [
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['middleware-plugin'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'router-test-plugin@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'middleware-plugin')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth')
|
||||
|
||||
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-plugin@docker')
|
||||
})
|
||||
|
||||
it('should render a complex middleware', async () => {
|
||||
const mockMiddleware = {
|
||||
name: 'middleware-complex',
|
||||
type: 'sample-middleware',
|
||||
status: 'enabled',
|
||||
provider: 'the-provider',
|
||||
usedBy: ['router-test-complex@docker'],
|
||||
redirectScheme: {
|
||||
scheme: 'redirect-scheme',
|
||||
},
|
||||
addPrefix: {
|
||||
prefix: 'add-prefix-sample',
|
||||
},
|
||||
basicAuth: {
|
||||
users: ['user1', 'user2'],
|
||||
usersFile: 'users/file',
|
||||
realm: 'realm-sample',
|
||||
removeHeader: true,
|
||||
headerField: 'basic-auth-header',
|
||||
},
|
||||
chain: {
|
||||
middlewares: ['chain-middleware-1', 'chain-middleware-2', 'chain-middleware-3'],
|
||||
},
|
||||
buffering: {
|
||||
maxRequestBodyBytes: 10000,
|
||||
memRequestBodyBytes: 10001,
|
||||
maxResponseBodyBytes: 10002,
|
||||
memResponseBodyBytes: 10003,
|
||||
retryExpression: 'buffer-retry-expression',
|
||||
},
|
||||
circuitBreaker: {
|
||||
expression: 'circuit-breaker',
|
||||
},
|
||||
compress: {},
|
||||
error: ['error-sample'],
|
||||
errors: {
|
||||
status: ['status-1', 'status-2'],
|
||||
service: 'errors-service',
|
||||
query: 'errors-query',
|
||||
},
|
||||
forwardAuth: {
|
||||
address: 'forward-auth-address',
|
||||
tls: {
|
||||
ca: 'tls-ca',
|
||||
caOptional: true,
|
||||
cert: 'tls-certificate',
|
||||
key: 'tls-key',
|
||||
insecureSkipVerify: true,
|
||||
},
|
||||
trustForwardHeader: true,
|
||||
authResponseHeaders: ['auth-response-header-1', 'auth-response-header-2'],
|
||||
},
|
||||
headers: {
|
||||
customRequestHeaders: {
|
||||
'req-header-a': 'custom-req-headers-a',
|
||||
'req-header-b': 'custom-req-headers-b',
|
||||
},
|
||||
customResponseHeaders: {
|
||||
'res-header-a': 'custom-res-headers-a',
|
||||
'res-header-b': 'custom-res-headers-b',
|
||||
},
|
||||
accessControlAllowCredentials: true,
|
||||
accessControlAllowHeaders: ['allowed-header-1', 'allowed-header-2'],
|
||||
accessControlAllowMethods: ['GET', 'POST', 'PUT'],
|
||||
accessControlAllowOrigin: 'allowed.origin',
|
||||
accessControlExposeHeaders: ['exposed-header-1', 'exposed-header-2'],
|
||||
accessControlMaxAge: 10004,
|
||||
addVaryHeader: true,
|
||||
allowedHosts: ['allowed-host-1', 'allowed-host-2'],
|
||||
hostsProxyHeaders: ['host-proxy-header-a', 'host-proxy-header-b'],
|
||||
sslRedirect: true,
|
||||
sslTemporaryRedirect: true,
|
||||
sslHost: 'ssl.host',
|
||||
sslProxyHeaders: {
|
||||
'proxy-header-a': 'ssl-proxy-header-a',
|
||||
'proxy-header-b': 'ssl-proxy-header-b',
|
||||
},
|
||||
sslForceHost: true,
|
||||
stsSeconds: 10005,
|
||||
stsIncludeSubdomains: true,
|
||||
stsPreload: true,
|
||||
forceSTSHeader: true,
|
||||
frameDeny: true,
|
||||
customFrameOptionsValue: 'custom-frame-options',
|
||||
contentTypeNosniff: true,
|
||||
browserXssFilter: true,
|
||||
customBrowserXSSValue: 'custom-xss-value',
|
||||
contentSecurityPolicy: 'content-security-policy',
|
||||
publicKey: 'public-key',
|
||||
referrerPolicy: 'referrer-policy',
|
||||
featurePolicy: 'feature-policy',
|
||||
isDevelopment: true,
|
||||
},
|
||||
ipWhiteList: {
|
||||
sourceRange: ['125.0.0.1', '125.0.0.4'],
|
||||
ipStrategy: {
|
||||
depth: 10006,
|
||||
excludedIPs: ['125.0.0.2', '125.0.0.3'],
|
||||
},
|
||||
},
|
||||
inFlightReq: {
|
||||
amount: 10007,
|
||||
sourceCriterion: {
|
||||
ipStrategy: {
|
||||
depth: 10008,
|
||||
excludedIPs: ['126.0.0.1', '126.0.0.2'],
|
||||
},
|
||||
requestHeaderName: 'inflight-req-header',
|
||||
requestHost: true,
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
average: 10009,
|
||||
burst: 10010,
|
||||
sourceCriterion: {
|
||||
ipStrategy: {
|
||||
depth: 10011,
|
||||
excludedIPs: ['127.0.0.1', '127.0.0.2'],
|
||||
},
|
||||
requestHeaderName: 'rate-limit-req-header',
|
||||
requestHost: true,
|
||||
},
|
||||
},
|
||||
passTLSClientCert: {
|
||||
pem: true,
|
||||
info: {
|
||||
notAfter: true,
|
||||
notBefore: true,
|
||||
sans: true,
|
||||
subject: {
|
||||
country: true,
|
||||
province: true,
|
||||
locality: true,
|
||||
organization: true,
|
||||
commonName: true,
|
||||
serialNumber: true,
|
||||
domainComponent: true,
|
||||
},
|
||||
issuer: {
|
||||
country: true,
|
||||
province: true,
|
||||
locality: true,
|
||||
organization: true,
|
||||
commonName: true,
|
||||
serialNumber: true,
|
||||
domainComponent: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
redirectRegex: {
|
||||
regex: '/redirect-from-regex',
|
||||
replacement: '/redirect-to',
|
||||
permanent: true,
|
||||
},
|
||||
replacePath: {
|
||||
path: '/replace-path',
|
||||
},
|
||||
replacePathRegex: {
|
||||
regex: 'replace-path-regex',
|
||||
replacement: 'replace-path-replacement',
|
||||
},
|
||||
retry: {
|
||||
attempts: 10012,
|
||||
},
|
||||
stripPrefix: {
|
||||
prefixes: ['strip-prefix1', 'strip-prefix2'],
|
||||
},
|
||||
stripPrefixRegex: {
|
||||
regex: ['strip-prefix-regex1', 'strip-prefix-regex2'],
|
||||
},
|
||||
plugin: {
|
||||
ldapAuth: {
|
||||
source: 'plugin-ldap-source',
|
||||
baseDN: 'plugin-ldap-base-dn',
|
||||
attribute: 'plugin-ldap-attribute',
|
||||
searchFilter: 'plugin-ldap-search-filter',
|
||||
forwardUsername: true,
|
||||
forwardUsernameHeader: 'plugin-ldap-forward-username-header',
|
||||
forwardAuthorization: true,
|
||||
wwwAuthenticateHeader: true,
|
||||
wwwAuthenticateHeaderRealm: 'plugin-ldap-www-authenticate-realm',
|
||||
},
|
||||
inFlightReq: {
|
||||
amount: 10013,
|
||||
sourceCriterion: {
|
||||
ipStrategy: {
|
||||
depth: 10014,
|
||||
excludedIPs: ['128.0.0.1', '128.0.0.2'],
|
||||
},
|
||||
requestHeaderName: 'plugin-inflight-req-header',
|
||||
requestHost: true,
|
||||
},
|
||||
},
|
||||
rateLimit: {
|
||||
average: 10015,
|
||||
burst: 10016,
|
||||
sourceCriterion: {
|
||||
ipStrategy: {
|
||||
depth: 10017,
|
||||
excludedIPs: ['129.0.0.1', '129.0.0.2'],
|
||||
},
|
||||
requestHeaderName: 'plugin-rate-limit-req-header',
|
||||
requestHost: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
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
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware 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('redirect-scheme')
|
||||
expect(middlewareCard.innerHTML).toContain('add-prefix-sample')
|
||||
expect(middlewareCard.innerHTML).toContain('buffer-retry-expression')
|
||||
expect(middlewareCard.innerHTML).toContain('circuit-breaker')
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['replace-path-regex', 'replace-path-replacement'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['/redirect-from-regex', '/redirect-to'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['127.0.0.1', '127.0.0.2', 'rate-limit-req-header'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['126.0.0.1', '126.0.0.2', 'inflight-req-header'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['125.0.0.1', '125.0.0.2', '125.0.0.3', '125.0.0.4'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['ssl.host', 'ssl-proxy-header-a', 'ssl-proxy-header-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['host-proxy-header-a', 'host-proxy-header-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-host-1', 'allowed-host-2'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['exposed-header-1', 'exposed-header-2'])
|
||||
expect(middlewareCard.innerHTML).toContain('allowed.origin')
|
||||
expect(middlewareCard.innerHTML).toContain('custom-frame-options')
|
||||
expect(middlewareCard.innerHTML).toContain('content-security-policy')
|
||||
expect(middlewareCard.innerHTML).toContain('public-key')
|
||||
expect(middlewareCard.innerHTML).toContain('referrer-policy')
|
||||
expect(middlewareCard.innerHTML).toContain('feature-policy')
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['GET', 'POST', 'PUT'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['allowed-header-1', 'allowed-header-2'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-res-headers-a', 'custom-res-headers-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple(['custom-req-headers-a', 'custom-req-headers-b'])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'forward-auth-address',
|
||||
'auth-response-header-1',
|
||||
'auth-response-header-2',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'error-sample',
|
||||
'status-1',
|
||||
'status-2',
|
||||
'errors-service',
|
||||
'errors-query',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'chain-middleware-1',
|
||||
'chain-middleware-2',
|
||||
'chain-middleware-3',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'user1',
|
||||
'user2',
|
||||
'users/file',
|
||||
'realm-sample',
|
||||
'basic-auth-header',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'strip-prefix1',
|
||||
'strip-prefix2',
|
||||
'strip-prefix-regex1',
|
||||
'strip-prefix-regex2',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'10000',
|
||||
'10001',
|
||||
'10002',
|
||||
'10003',
|
||||
'10004',
|
||||
'10005',
|
||||
'10006',
|
||||
'10007',
|
||||
'10008',
|
||||
'10009',
|
||||
'10010',
|
||||
'10011',
|
||||
'10012',
|
||||
])
|
||||
expect(middlewareCard.innerHTML).toIncludeMultiple([
|
||||
'plugin-ldap-source',
|
||||
'plugin-ldap-base-dn',
|
||||
'plugin-ldap-attribute',
|
||||
'plugin-ldap-search-filter',
|
||||
'plugin-ldap-forward-username-header',
|
||||
'plugin-ldap-www-authenticate-realm',
|
||||
'plugin-inflight-req-header',
|
||||
'plugin-rate-limit-req-header',
|
||||
'10013',
|
||||
'10014',
|
||||
'10015',
|
||||
'10016',
|
||||
'10017',
|
||||
])
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('should render a plugin middleware with no type', async () => {
|
||||
const mockMiddleware = {
|
||||
plugin: {
|
||||
jwtAuth: {
|
||||
child: {},
|
||||
sibling: {
|
||||
negativeGrandChild: false,
|
||||
positiveGrandChild: true,
|
||||
},
|
||||
stringChild: '123',
|
||||
arrayChild: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
status: 'enabled',
|
||||
name: 'middleware-plugin-no-type',
|
||||
provider: 'docker',
|
||||
routers: [],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpMiddlewareRender name="mock-middleware" data={mockMiddleware as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const headings = Array.from(container.getElementsByTagName('h1'))
|
||||
const titleTags = headings.filter((h1) => h1.innerHTML === 'middleware-plugin-no-type')
|
||||
expect(titleTags.length).toBe(1)
|
||||
|
||||
const middlewareCard = getByTestId('middleware-card')
|
||||
expect(middlewareCard.innerHTML).toContain('Success')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > negative Grand Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > sibling > positive Grand Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > string Child')
|
||||
expect(middlewareCard.innerHTML).toContain('jwtAuth > array Child')
|
||||
|
||||
const childSpans = Array.from(middlewareCard.querySelectorAll('span')).filter((span) =>
|
||||
['0', '1', '2', '3', '123'].includes(span.innerHTML),
|
||||
)
|
||||
expect(childSpans.length).toBe(7)
|
||||
})
|
||||
})
|
||||
73
webui/src/pages/http/HttpMiddleware.tsx
Normal file
73
webui/src/pages/http/HttpMiddleware.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { Box, Card, 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 HttpMiddlewareRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpMiddlewareRender = ({ data, error, name }: HttpMiddlewareRenderProps) => {
|
||||
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 data-testid="skeletons">
|
||||
<DetailSectionSkeleton />
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSkeleton />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<MiddlewareGrid>
|
||||
<Card css={{ p: '$3' }} data-testid="middleware-card">
|
||||
<RenderMiddleware middleware={data} />
|
||||
</Card>
|
||||
</MiddlewareGrid>
|
||||
<UsedByRoutersSection data-testid="routers-table" data={data} protocol="http" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const HttpMiddleware = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'middlewares')
|
||||
return <HttpMiddlewareRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default HttpMiddleware
|
||||
129
webui/src/pages/http/HttpMiddlewares.spec.tsx
Normal file
129
webui/src/pages/http/HttpMiddlewares.spec.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { HttpMiddlewares as HttpMiddlewaresPage, HttpMiddlewaresRender, makeRowRender } from './HttpMiddlewares'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpMiddlewaresPage />', () => {
|
||||
it('should render the middleware list', () => {
|
||||
const pages = [
|
||||
{
|
||||
addPrefix: { prefix: '/foo' },
|
||||
status: 'enabled',
|
||||
usedBy: ['web@docker'],
|
||||
name: 'add-foo@docker',
|
||||
provider: 'docker',
|
||||
type: 'addprefix',
|
||||
},
|
||||
{
|
||||
addPrefix: { prefix: '/path' },
|
||||
error: ['message 1', 'message 2'],
|
||||
status: 'disabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware00@docker',
|
||||
provider: 'docker',
|
||||
type: 'addprefix',
|
||||
},
|
||||
{
|
||||
basicAuth: {
|
||||
users: ['test:$apr1$H6uskkkW$IgXLP6ewTrSuBkTrqE8wj/', 'test2:$apr1$d9hr9HBB$4HxwgUir3HP4EsggP/QNo0'],
|
||||
usersFile: '/etc/foo/my/file/path/.htpasswd',
|
||||
realm: 'Hello you are here',
|
||||
removeHeader: true,
|
||||
headerField: 'X-WebAuth-User',
|
||||
},
|
||||
error: ['message 1', 'message 2'],
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware01@docker',
|
||||
provider: 'docker',
|
||||
type: 'basicauth',
|
||||
},
|
||||
{
|
||||
buffering: {
|
||||
maxRequestBodyBytes: 42,
|
||||
memRequestBodyBytes: 42,
|
||||
maxResponseBodyBytes: 42,
|
||||
memResponseBodyBytes: 42,
|
||||
retryExpression: 'IsNetworkError() \u0026\u0026 Attempts() \u003c 2',
|
||||
},
|
||||
error: ['message 1', 'message 2'],
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware02@docker',
|
||||
provider: 'docker',
|
||||
type: 'buffering',
|
||||
},
|
||||
{
|
||||
chain: {
|
||||
middlewares: [
|
||||
'middleware01@docker',
|
||||
'middleware021@docker',
|
||||
'middleware03@docker',
|
||||
'middleware06@docker',
|
||||
'middleware10@docker',
|
||||
],
|
||||
},
|
||||
error: ['message 1', 'message 2'],
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker', 'bar@file'],
|
||||
name: 'middleware03@docker',
|
||||
provider: 'docker',
|
||||
type: 'chain',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<HttpMiddlewaresPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('add-foo@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('addprefix')
|
||||
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('middleware00@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('addprefix')
|
||||
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('middleware01@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('basicauth')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('middleware02@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('buffering')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[4].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[4].innerHTML).toContain('middleware03@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[4].innerHTML).toContain('chain')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[4].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<HttpMiddlewaresRender
|
||||
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/http/HttpMiddlewares.tsx
Normal file
125
webui/src/pages/http/HttpMiddlewares.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 HttpMiddlewaresRenderRow = (row) => {
|
||||
const middlewareType = parseMiddlewareType(row)
|
||||
|
||||
return (
|
||||
<ClickableRow key={row.name} to={`/http/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 HttpMiddlewaresRenderRow
|
||||
}
|
||||
|
||||
export const HttpMiddlewaresRender = ({
|
||||
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 HttpMiddlewares = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/http/middlewares',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="HTTP Middlewares">
|
||||
<TableFilter />
|
||||
<HttpMiddlewaresRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
121
webui/src/pages/http/HttpRouter.spec.tsx
Normal file
121
webui/src/pages/http/HttpRouter.spec.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { HttpRouterRender } from './HttpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import apiEntrypoints from 'mocks/data/api-entrypoints.json'
|
||||
import apiHttpMiddlewares from 'mocks/data/api-http_middlewares.json'
|
||||
import apiHttpRouters from 'mocks/data/api-http_routers.json'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpRouterRender name="mock-router" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the router details', async () => {
|
||||
const router = apiHttpRouters.find((x) => x.name === 'orphan-router@file')
|
||||
const mockData = {
|
||||
...router!,
|
||||
middlewares: apiHttpMiddlewares.filter((x) => router?.middlewares?.includes(x.name)),
|
||||
hasValidMiddlewares: true,
|
||||
entryPointsData: apiEntrypoints.filter((x) => router?.using?.includes(x.name)),
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpRouterRender name="mock-router" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const routerStructure = getByTestId('router-structure')
|
||||
expect(routerStructure.innerHTML).toContain(':80')
|
||||
expect(routerStructure.innerHTML).toContain(':443')
|
||||
expect(routerStructure.innerHTML).toContain(':8080')
|
||||
expect(routerStructure.innerHTML).toContain(':8002')
|
||||
expect(routerStructure.innerHTML).toContain(':8003')
|
||||
expect(routerStructure.innerHTML).toContain('orphan-router@file')
|
||||
expect(routerStructure.innerHTML).toContain('middleware00')
|
||||
expect(routerStructure.innerHTML).toContain('middleware01')
|
||||
expect(routerStructure.innerHTML).toContain('middleware02')
|
||||
expect(routerStructure.innerHTML).toContain('middleware03')
|
||||
expect(routerStructure.innerHTML).toContain('middleware04')
|
||||
expect(routerStructure.innerHTML).toContain('middleware05')
|
||||
expect(routerStructure.innerHTML).toContain('middleware06')
|
||||
expect(routerStructure.innerHTML).toContain('middleware07')
|
||||
expect(routerStructure.innerHTML).toContain('middleware08')
|
||||
expect(routerStructure.innerHTML).toContain('middleware09')
|
||||
expect(routerStructure.innerHTML).toContain('middleware10')
|
||||
expect(routerStructure.innerHTML).toContain('middleware11')
|
||||
expect(routerStructure.innerHTML).toContain('middleware12')
|
||||
expect(routerStructure.innerHTML).toContain('middleware13')
|
||||
expect(routerStructure.innerHTML).toContain('middleware14')
|
||||
expect(routerStructure.innerHTML).toContain('middleware15')
|
||||
expect(routerStructure.innerHTML).toContain('middleware16')
|
||||
expect(routerStructure.innerHTML).toContain('middleware17')
|
||||
expect(routerStructure.innerHTML).toContain('middleware18')
|
||||
expect(routerStructure.innerHTML).toContain('middleware19')
|
||||
expect(routerStructure.innerHTML).toContain('middleware20')
|
||||
expect(routerStructure.innerHTML).toContain('unexistingservice')
|
||||
expect(routerStructure.innerHTML).toContain('HTTP Router')
|
||||
expect(routerStructure.innerHTML).not.toContain('TCP Router')
|
||||
|
||||
const routerDetailsSection = getByTestId('router-detail')
|
||||
|
||||
const routerDetailsPanel = routerDetailsSection.querySelector(':scope > div:nth-child(1)')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('orphan-router@file')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Error')
|
||||
expect(routerDetailsPanel?.querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||
expect(routerDetailsPanel?.innerHTML).toContain(
|
||||
'Path(`somethingreallyunexpectedbutalsoverylongitgetsoutofthecontainermaybe`)',
|
||||
)
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('unexistingservice')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('the service "unexistingservice@file" does not exist')
|
||||
|
||||
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('middleware02')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware03')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware04')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware05')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware06')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware07')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware08')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware09')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware10')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware11')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware12')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware13')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware14')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware15')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware16')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware17')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware18')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware19')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('middleware20')
|
||||
expect(middlewaresPanel?.innerHTML).toContain('Success')
|
||||
expect(providers.length).toBe(21)
|
||||
|
||||
expect(getByTestId('/http/middlewares/middleware00@docker')).toBeInTheDocument()
|
||||
|
||||
expect(getByTestId('/http/middlewares/middleware01@docker')).toBeInTheDocument()
|
||||
|
||||
expect(getByTestId('/http/services/unexistingservice@file')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
152
webui/src/pages/http/HttpRouter.tsx
Normal file
152
webui/src/pages/http/HttpRouter.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
import { useContext, useEffect, useMemo } from 'react'
|
||||
import { FiGlobe, FiLayers, FiLogIn, FiZap } from 'react-icons/fi'
|
||||
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 { 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'
|
||||
|
||||
const CardListColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(4, 1fr)',
|
||||
marginBottom: '48px',
|
||||
})
|
||||
|
||||
type DetailProps = {
|
||||
data: ResourceDetailDataType
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
export const RouterStructure = ({ data, protocol = 'http' }: DetailProps) => {
|
||||
const { addToast } = useContext(ToastContext)
|
||||
const entrypoints = useMemo(() => getValidData(data.entryPointsData), [data?.entryPointsData])
|
||||
const entrypointsError = useMemo(() => getErrorData(data.entryPointsData), [data?.entryPointsData])
|
||||
|
||||
const serviceSlug = data.service?.includes('@')
|
||||
? data.service
|
||||
: `${data.service ?? 'unknown'}@${data.provider ?? 'unknown'}`
|
||||
|
||||
useEffect(() => {
|
||||
entrypointsError?.map((error) =>
|
||||
addToast({
|
||||
message: error.message,
|
||||
severity: 'error',
|
||||
}),
|
||||
)
|
||||
}, [addToast, entrypointsError])
|
||||
|
||||
return (
|
||||
<CardListColumns data-testid="router-structure">
|
||||
{entrypoints.length > 0 && (
|
||||
<CardListSection
|
||||
bigDescription
|
||||
icon={<FiLogIn size={20} />}
|
||||
title="Entrypoints"
|
||||
cards={data.entryPointsData?.map((ep: EntryPoint) => ({
|
||||
title: ep.name,
|
||||
description: ep.address,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<CardListSection
|
||||
icon={<FiGlobe size={20} />}
|
||||
title={`${protocol.toUpperCase()} Router`}
|
||||
cards={[{ title: 'router', description: data.name, focus: true }]}
|
||||
/>
|
||||
{data.hasValidMiddlewares && (
|
||||
<CardListSection
|
||||
icon={<FiLayers size={20} />}
|
||||
title={`${protocol.toUpperCase()} Middlewares`}
|
||||
cards={data.middlewares?.map((mw) => ({
|
||||
title: parseMiddlewareType(mw) ?? 'middleware',
|
||||
description: mw.name,
|
||||
link: `/${protocol}/middlewares/${mw.name}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<CardListSection
|
||||
isLast
|
||||
icon={<FiZap size={20} />}
|
||||
title="Service"
|
||||
cards={[{ title: 'service', description: data.service, link: `/${protocol}/services/${serviceSlug}` }]}
|
||||
/>
|
||||
</CardListColumns>
|
||||
)
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const RouterDetail = ({ data }: DetailProps) => (
|
||||
<SpacedColumns data-testid="router-detail">
|
||||
<RouterPanel data={data} />
|
||||
<TlsPanel data={data} />
|
||||
<MiddlewarePanel data={data} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type HttpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error | null
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpRouterRender = ({ data, error, name }: HttpRouterRenderProps) => {
|
||||
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 />
|
||||
<CardListSection isLast />
|
||||
</Flex>
|
||||
<SpacedColumns>
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
<DetailSectionSkeleton />
|
||||
</SpacedColumns>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<RouterStructure data={data} protocol="http" />
|
||||
<RouterDetail data={data} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const HttpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers')
|
||||
return <HttpRouterRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default HttpRouter
|
||||
109
webui/src/pages/http/HttpRouters.spec.tsx
Normal file
109
webui/src/pages/http/HttpRouters.spec.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { HttpRouters as HttpRoutersPage, HttpRoutersRender, makeRowRender } from 'pages/http/HttpRouters'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpRoutersPage />', () => {
|
||||
it('should render the routers list', () => {
|
||||
const pages = [
|
||||
{
|
||||
service: 'jaeger_v2-example-beta1',
|
||||
rule: 'Host(`jaeger-v2-example-beta1`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'jaeger_v2-example-beta1@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
middlewares: ['middleware00@docker', 'middleware01@docker', 'middleware02@docker'],
|
||||
service: 'unexistingservice',
|
||||
rule: 'Path(`somethingreallyunexpected`)',
|
||||
error: ['the service "unexistingservice@file" does not exist'],
|
||||
status: 'disabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'orphan-router@file',
|
||||
provider: 'file',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-redirect'],
|
||||
middlewares: ['redirect@file'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
status: 'enabled',
|
||||
using: ['web-redirect'],
|
||||
name: 'server-redirect@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-secured'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-secured'],
|
||||
name: 'server-secured@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<HttpRoutersPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).not.toContain('testid="tls-on"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('Host(`jaeger-v2-example-beta1`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toIncludeMultiple(['web-secured', 'web'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('jaeger_v2-example-beta1@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('jaeger_v2-example-beta1')
|
||||
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).not.toContain('testid="tls-on"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('Path(`somethingreallyunexpected`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toIncludeMultiple(['web-secured', 'web'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('orphan-router@file')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('unexistingservice')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).not.toContain('testid="tls-on"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('Host(`server`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toIncludeMultiple(['web-redirect'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('server-redirect@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('api2_v2-example-beta1')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('testid="tls-on"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('Host(`server`)')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toIncludeMultiple(['web-secured'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('server-secured@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('api2_v2-example-beta1')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<HttpRoutersRender
|
||||
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')
|
||||
})
|
||||
})
|
||||
146
webui/src/pages/http/HttpRouters.tsx
Normal file
146
webui/src/pages/http/HttpRouters.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
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 = (protocol = 'http'): RenderRowType => {
|
||||
const HttpRoutersRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/${protocol}/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</AriaTd>
|
||||
{protocol !== 'udp' && (
|
||||
<>
|
||||
<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.using && row.using.length > 0 && <Chips items={row.using} />}</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 HttpRoutersRenderRow
|
||||
}
|
||||
|
||||
export const HttpRoutersRender = ({
|
||||
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" css={{ width: '40px' }} isSortable sortByValue="provider" />
|
||||
<SortableTh label="Priority" css={{ width: '60px' }} 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 HttpRouters = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/http/routers',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="HTTP Routers">
|
||||
<TableFilter />
|
||||
<HttpRoutersRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
220
webui/src/pages/http/HttpService.spec.tsx
Normal file
220
webui/src/pages/http/HttpService.spec.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { HttpServiceRender } from './HttpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<HttpServiceRender name="mock-service" data={{} as ResourceDetailDataType} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('Not found page')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a service with no health check or mirrors', async () => {
|
||||
const mockData = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: 'http://10.0.1.12:80',
|
||||
},
|
||||
],
|
||||
passHostHeader: true,
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['router-test1@docker', 'router-test2@docker'],
|
||||
serverStatus: {
|
||||
'http://10.0.1.12:80': 'UP',
|
||||
},
|
||||
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',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-secured'],
|
||||
service: 'api2_v2-example-beta1',
|
||||
rule: 'Host(`server`)',
|
||||
tls: {},
|
||||
status: 'enabled',
|
||||
using: ['web-secured'],
|
||||
name: 'router-test2@docker',
|
||||
provider: 'docker',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender 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.innerHTML).toContain('docker')
|
||||
expect(serviceDetails.innerHTML).toContain('Status')
|
||||
expect(serviceDetails.innerHTML).toContain('Success')
|
||||
expect(serviceDetails.innerHTML).toContain('Pass Host Header')
|
||||
expect(serviceDetails.innerHTML).toContain('True')
|
||||
|
||||
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(2)
|
||||
expect(tableBody?.innerHTML).toContain('router-test1@docker')
|
||||
expect(tableBody?.innerHTML).toContain('router-test2@docker')
|
||||
|
||||
expect(() => {
|
||||
getByTestId('health-check')
|
||||
}).toThrow('Unable to find an element by: [data-testid="health-check"]')
|
||||
|
||||
expect(() => {
|
||||
getByTestId('mirror-services')
|
||||
}).toThrow('Unable to find an element by: [data-testid="mirror-services"]')
|
||||
})
|
||||
|
||||
it('should render a service with health check', async () => {
|
||||
const mockData = {
|
||||
loadBalancer: {
|
||||
servers: [
|
||||
{
|
||||
url: 'http://10.0.1.12:81',
|
||||
},
|
||||
],
|
||||
passHostHeader: true,
|
||||
healthCheck: {
|
||||
scheme: 'https',
|
||||
path: '/health',
|
||||
port: 80,
|
||||
interval: '5s',
|
||||
timeout: '10s',
|
||||
hostname: 'domain.com',
|
||||
headers: {
|
||||
'X-Custom-A': 'foobar,gi,ji;ji,ok',
|
||||
'X-Custom-B': 'foobar foobar foobar foobar foobar',
|
||||
},
|
||||
},
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: [],
|
||||
serverStatus: {
|
||||
'http://10.0.1.12:81': 'UP',
|
||||
},
|
||||
name: 'service-test2',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
routers: [],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const healthCheck = getByTestId('health-check')
|
||||
expect(healthCheck.innerHTML).toContain('Scheme')
|
||||
expect(healthCheck.innerHTML).toContain('https')
|
||||
expect(healthCheck.innerHTML).toContain('Interval')
|
||||
expect(healthCheck.innerHTML).toContain('5s')
|
||||
expect(healthCheck.innerHTML).toContain('Path')
|
||||
expect(healthCheck.innerHTML).toContain('/health')
|
||||
expect(healthCheck.innerHTML).toContain('Timeout')
|
||||
expect(healthCheck.innerHTML).toContain('10s')
|
||||
expect(healthCheck.innerHTML).toContain('Port')
|
||||
expect(healthCheck.innerHTML).toContain('80')
|
||||
expect(healthCheck.innerHTML).toContain('Hostname')
|
||||
expect(healthCheck.innerHTML).toContain('domain.com')
|
||||
expect(healthCheck.innerHTML).toContain('Headers')
|
||||
expect(healthCheck.innerHTML).toContain('X-Custom-A: foobar,gi,ji;ji,ok')
|
||||
expect(healthCheck.innerHTML).toContain('X-Custom-B: foobar foobar foobar foobar foobar')
|
||||
|
||||
expect(() => {
|
||||
getByTestId('mirror-services')
|
||||
}).toThrow('Unable to find an element by: [data-testid="mirror-services"]')
|
||||
})
|
||||
|
||||
it('should render a service with mirror services', async () => {
|
||||
const mockData = {
|
||||
mirroring: {
|
||||
service: 'one@docker',
|
||||
mirrors: [
|
||||
{
|
||||
name: 'two@docker',
|
||||
percent: 10,
|
||||
},
|
||||
{
|
||||
name: 'three@docker',
|
||||
percent: 15,
|
||||
},
|
||||
{
|
||||
name: 'four@docker',
|
||||
percent: 80,
|
||||
},
|
||||
],
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: [],
|
||||
name: 'service-test3',
|
||||
provider: 'docker',
|
||||
type: 'mirroring',
|
||||
routers: [],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<HttpServiceRender name="mock-service" data={mockData as any} error={undefined} />,
|
||||
)
|
||||
|
||||
const mirrorServices = getByTestId('mirror-services')
|
||||
const providers = Array.from(mirrorServices.querySelectorAll('svg[data-testid="docker"]'))
|
||||
expect(mirrorServices.childNodes.length).toBe(3)
|
||||
expect(mirrorServices.innerHTML).toContain('two@docker')
|
||||
expect(mirrorServices.innerHTML).toContain('three@docker')
|
||||
expect(mirrorServices.innerHTML).toContain('four@docker')
|
||||
expect(mirrorServices.innerHTML).toContain('10')
|
||||
expect(mirrorServices.innerHTML).toContain('15')
|
||||
expect(mirrorServices.innerHTML).toContain('80')
|
||||
expect(providers.length).toBe(3)
|
||||
|
||||
expect(() => {
|
||||
getByTestId('health-check')
|
||||
}).toThrow('Unable to find an element by: [data-testid="health-check"]')
|
||||
|
||||
expect(() => {
|
||||
getByTestId('servers-list')
|
||||
}).toThrow('Unable to find an element by: [data-testid="servers-list"]')
|
||||
})
|
||||
})
|
||||
314
webui/src/pages/http/HttpService.tsx
Normal file
314
webui/src/pages/http/HttpService.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
import { Badge, Box, Flex, H1, Skeleton, styled, Text } from '@traefiklabs/faency'
|
||||
import { useMemo } from 'react'
|
||||
import { FiGlobe, FiInfo, FiShield } from 'react-icons/fi'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import ProviderIcon from 'components/icons/providers'
|
||||
import {
|
||||
BooleanState,
|
||||
Chips,
|
||||
DetailSection,
|
||||
DetailSectionSkeleton,
|
||||
ItemBlock,
|
||||
ItemTitle,
|
||||
LayoutTwoCols,
|
||||
ProviderName,
|
||||
} from 'components/resources/DetailSections'
|
||||
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 = {
|
||||
data: ServiceDetailType
|
||||
protocol?: string
|
||||
}
|
||||
|
||||
const SpacedColumns = styled(Flex, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||
gridGap: '16px',
|
||||
})
|
||||
|
||||
const ServicesGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const ServersGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
})
|
||||
|
||||
const MirrorsGrid = styled(Box, {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '2fr 1fr 1fr',
|
||||
alignItems: 'center',
|
||||
padding: '$3 $5',
|
||||
borderBottom: '1px solid $tableRowBorder',
|
||||
|
||||
'> *:not(:first-child)': {
|
||||
justifySelf: 'flex-end',
|
||||
},
|
||||
})
|
||||
|
||||
const GridTitle = styled(Text, {
|
||||
fontSize: '14px',
|
||||
fontWeight: 700,
|
||||
color: 'hsl(0, 0%, 56%)',
|
||||
})
|
||||
|
||||
type Server = {
|
||||
url: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
type ServerStatus = {
|
||||
[server: string]: string
|
||||
}
|
||||
|
||||
function getServerStatusList(data: ServiceDetailType): ServerStatus {
|
||||
const serversList: ServerStatus = {}
|
||||
|
||||
data.loadBalancer?.servers?.forEach((server: Server) => {
|
||||
serversList[server.address || server.url] = 'DOWN'
|
||||
})
|
||||
|
||||
if (data.serverStatus) {
|
||||
Object.entries(data.serverStatus).forEach(([server, status]) => {
|
||||
serversList[server] = status
|
||||
})
|
||||
}
|
||||
|
||||
return serversList
|
||||
}
|
||||
|
||||
export const ServicePanels = ({ data, protocol = '' }: DetailProps) => {
|
||||
const serversList = getServerStatusList(data)
|
||||
const getProviderFromName = (serviceName: string): string => {
|
||||
const [, provider] = serviceName.split('@')
|
||||
return provider || data.provider
|
||||
}
|
||||
const providerName = useMemo(() => {
|
||||
return data.provider
|
||||
}, [data.provider])
|
||||
|
||||
return (
|
||||
<SpacedColumns css={{ mb: '$5', pb: '$5' }} data-testid="service-details">
|
||||
<DetailSection narrow icon={<FiInfo size={20} />} title="Service Details">
|
||||
<LayoutTwoCols>
|
||||
{data.type && (
|
||||
<ItemBlock title="Type">
|
||||
<Text css={{ lineHeight: '32px' }}>{data.type}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.provider && (
|
||||
<ItemBlock title="Provider">
|
||||
<ProviderIcon name={data.provider} />
|
||||
<ProviderName css={{ ml: '$2' }}>{providerName}</ProviderName>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.status && (
|
||||
<ItemBlock title="Status">
|
||||
<ResourceStatus status={data.status} withLabel />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.mirroring && data.mirroring.service && (
|
||||
<ItemBlock title="Main Service">
|
||||
<Badge>{data.mirroring.service}</Badge>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer && (
|
||||
<>
|
||||
{data.loadBalancer.passHostHeader && (
|
||||
<ItemBlock title="Pass Host Header">
|
||||
<BooleanState enabled={data.loadBalancer.passHostHeader} />
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.terminationDelay && (
|
||||
<ItemBlock title="Termination Delay">
|
||||
<Text>{`${data.loadBalancer.terminationDelay} ms`}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DetailSection>
|
||||
{data.loadBalancer?.healthCheck && (
|
||||
<DetailSection narrow icon={<FiShield size={20} />} title="Health Check">
|
||||
<Box data-testid="health-check">
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.scheme && (
|
||||
<ItemBlock title="Scheme">
|
||||
<Text>{data.loadBalancer.healthCheck.scheme}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.interval && (
|
||||
<ItemBlock title="Interval">
|
||||
<Text>{data.loadBalancer.healthCheck.interval}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.path && (
|
||||
<ItemBlock title="Path">
|
||||
<Tooltip label={data.loadBalancer.healthCheck.path} action="copy">
|
||||
<Text>{data.loadBalancer.healthCheck.path}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.timeout && (
|
||||
<ItemBlock title="Timeout">
|
||||
<Text>{data.loadBalancer.healthCheck.timeout}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
<LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.port && (
|
||||
<ItemBlock title="Port">
|
||||
<Text>{data.loadBalancer.healthCheck.port}</Text>
|
||||
</ItemBlock>
|
||||
)}
|
||||
{data.loadBalancer.healthCheck.hostname && (
|
||||
<ItemBlock title="Hostname">
|
||||
<Tooltip label={data.loadBalancer.healthCheck.hostname} action="copy">
|
||||
<Text>{data.loadBalancer.healthCheck.hostname}</Text>
|
||||
</Tooltip>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</LayoutTwoCols>
|
||||
{data.loadBalancer.healthCheck.headers && (
|
||||
<ItemBlock title="Headers">
|
||||
<Chips
|
||||
variant="neon"
|
||||
items={Object.entries(data.loadBalancer.healthCheck.headers).map((entry) => entry.join(': '))}
|
||||
/>
|
||||
</ItemBlock>
|
||||
)}
|
||||
</Box>
|
||||
</DetailSection>
|
||||
)}
|
||||
{!!data?.weighted?.services?.length && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Services" noPadding>
|
||||
<>
|
||||
<ServicesGrid css={{ mt: '$2' }}>
|
||||
<GridTitle>Name</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Weight</GridTitle>
|
||||
<GridTitle css={{ textAlign: 'center' }}>Provider</GridTitle>
|
||||
</ServicesGrid>
|
||||
<Box data-testid="servers-list">
|
||||
{data.weighted.services.map((service) => (
|
||||
<ServicesGrid key={service.name}>
|
||||
<Text>{service.name}</Text>
|
||||
<Text css={{ textAlign: 'center' }}>{service.weight}</Text>
|
||||
<Flex css={{ justifyContent: 'center' }}>
|
||||
<ProviderIcon name={getProviderFromName(service.name)} />
|
||||
</Flex>
|
||||
</ServicesGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
{Object.keys(serversList).length > 0 && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Servers" noPadding>
|
||||
<>
|
||||
<ServersGrid css={{ gridTemplateColumns: protocol === 'http' ? '25% auto' : 'inherit', mt: '$2' }}>
|
||||
{protocol === 'http' && <ItemTitle css={{ mb: 0 }}>Status</ItemTitle>}
|
||||
<ItemTitle css={{ mb: 0 }}>URL</ItemTitle>
|
||||
</ServersGrid>
|
||||
<Box data-testid="servers-list">
|
||||
{Object.entries(serversList).map(([server, status]) => (
|
||||
<ServersGrid key={server} css={{ gridTemplateColumns: protocol === 'http' ? '25% auto' : 'inherit' }}>
|
||||
{protocol === 'http' && <ResourceStatus status={status === 'UP' ? 'enabled' : 'disabled'} />}
|
||||
<Box>
|
||||
<Tooltip label={server} action="copy">
|
||||
<Text>{server}</Text>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</ServersGrid>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
</DetailSection>
|
||||
)}
|
||||
{data.mirroring?.mirrors && data.mirroring.mirrors.length > 0 && (
|
||||
<DetailSection narrow icon={<FiGlobe size={20} />} title="Mirror Services" noPadding>
|
||||
<MirrorsGrid css={{ mt: '$2' }}>
|
||||
<GridTitle>Name</GridTitle>
|
||||
<GridTitle>Percent</GridTitle>
|
||||
<GridTitle>Provider</GridTitle>
|
||||
</MirrorsGrid>
|
||||
<Box data-testid="mirror-services">
|
||||
{data.mirroring.mirrors.map((mirror) => (
|
||||
<MirrorsGrid key={mirror.name}>
|
||||
<Text>{mirror.name}</Text>
|
||||
<Text>{mirror.percent}</Text>
|
||||
<ProviderIcon name={getProviderFromName(mirror.name)} />
|
||||
</MirrorsGrid>
|
||||
))}
|
||||
</Box>
|
||||
</DetailSection>
|
||||
)}
|
||||
</SpacedColumns>
|
||||
)
|
||||
}
|
||||
|
||||
type HttpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const HttpServiceRender = ({ data, error, name }: HttpServiceRenderProps) => {
|
||||
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 />
|
||||
<DetailSectionSkeleton narrow />
|
||||
</SpacedColumns>
|
||||
<UsedByRoutersSkeleton />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
return <NotFound />
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title={name}>
|
||||
<H1 css={{ mb: '$7' }}>{data.name}</H1>
|
||||
<ServicePanels data={data} protocol="http" />
|
||||
<UsedByRoutersSection data={data} protocol="http" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const HttpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services')
|
||||
return <HttpServiceRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default HttpService
|
||||
101
webui/src/pages/http/HttpServices.spec.tsx
Normal file
101
webui/src/pages/http/HttpServices.spec.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { HttpServices as HttpServicesPage, HttpServicesRender, makeRowRender } from './HttpServices'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<HttpServicesPage />', () => {
|
||||
it('should render the services list', () => {
|
||||
const pages = [
|
||||
{
|
||||
loadBalancer: { servers: [{ url: 'http://10.0.1.12:80' }], passHostHeader: true },
|
||||
status: 'enabled',
|
||||
usedBy: ['server-redirect@docker', 'server-secured@docker'],
|
||||
serverStatus: { 'http://10.0.1.12:80': 'UP' },
|
||||
name: 'api2_v2-example-beta1@docker',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
loadBalancer: {
|
||||
servers: [{ url: 'http://10.0.1.11:80' }, { url: 'http://10.0.1.12:80' }],
|
||||
passHostHeader: true,
|
||||
},
|
||||
status: 'enabled',
|
||||
usedBy: ['web@docker'],
|
||||
serverStatus: { 'http://10.0.1.11:80': 'UP' },
|
||||
name: 'api_v2-example-beta2@docker',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
weighted: { sticky: { cookie: { name: 'chocolat', secure: true, httpOnly: true } } },
|
||||
status: 'enabled',
|
||||
usedBy: ['foo@docker'],
|
||||
name: 'canary1@docker',
|
||||
provider: 'docker',
|
||||
type: 'weighted',
|
||||
},
|
||||
{
|
||||
weighted: { sticky: { cookie: {} } },
|
||||
status: 'enabled',
|
||||
usedBy: ['fii@docker'],
|
||||
name: 'canary2@file',
|
||||
provider: 'file',
|
||||
type: 'weighted',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<HttpServicesPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('api2_v2-example-beta1@docker')
|
||||
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="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('api_v2-example-beta2@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('loadbalancer')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('2')
|
||||
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('canary1@docker')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('weighted')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('0')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].querySelector('svg[data-testid="docker"]')).toBeTruthy()
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('canary2@file')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('weighted')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].innerHTML).toContain('0')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[3].querySelector('svg[data-testid="file"]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should render "No data available" when the API returns empty array', async () => {
|
||||
const { container, getByTestId } = renderWithProviders(
|
||||
<HttpServicesRender
|
||||
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/http/HttpServices.tsx
Normal file
124
webui/src/pages/http/HttpServices.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 HttpServicesRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/http/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 HttpServicesRenderRow
|
||||
}
|
||||
|
||||
export const HttpServicesRender = ({
|
||||
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 HttpServices = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/http/services',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="HTTP Services">
|
||||
<TableFilter />
|
||||
<HttpServicesRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: 'http-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: 'PageHTTPMiddlewares',
|
||||
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('http', { allMiddlewares: 'allMiddlewares' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('http/getAllMiddlewaresClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { 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: 'http-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: 'PageHTTPRouters',
|
||||
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('http', { allRouters: 'allRouters' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('http/getAllRoutersClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { 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,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: 'http-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 { 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: 'PageHTTPServices',
|
||||
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('http', { allServices: 'allServices' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('http/getAllServicesClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('http', { 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>
|
||||
6
webui/src/pages/http/index.ts
Normal file
6
webui/src/pages/http/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { HttpMiddleware } from './HttpMiddleware'
|
||||
export { HttpMiddlewares } from './HttpMiddlewares'
|
||||
export { HttpRouter } from './HttpRouter'
|
||||
export { HttpRouters } from './HttpRouters'
|
||||
export { HttpService } from './HttpService'
|
||||
export { HttpServices } from './HttpServices'
|
||||
7
webui/src/pages/index.ts
Normal file
7
webui/src/pages/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import * as HTTPPages from './http'
|
||||
import * as TCPPages from './tcp'
|
||||
import * as UDPPages from './udp'
|
||||
|
||||
export { Dashboard } from './dashboard/Dashboard'
|
||||
export { NotFound } from './NotFound'
|
||||
export { HTTPPages, TCPPages, UDPPages }
|
||||
|
|
@ -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'
|
||||
|
|
@ -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: 'udp-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: 'PageUDPRouters',
|
||||
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('udp', { allRouters: 'allRouters' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('udp/getAllRoutersClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('udp', { getAllRouters: 'getAllRouters' }),
|
||||
getAllRoutersWithParams (params) {
|
||||
return this.getAllRouters({
|
||||
query: this.filter,
|
||||
status: this.status,
|
||||
sortBy: this.sortBy,
|
||||
direction: this.sortDir,
|
||||
serviceName: '',
|
||||
middlewareName: '',
|
||||
...params
|
||||
})
|
||||
},
|
||||
refreshAll () {
|
||||
if (this.allRouters.loading) {
|
||||
return
|
||||
}
|
||||
|
||||
this.initFetch()
|
||||
},
|
||||
handleLoadMore ({ page = 1 } = {}) {
|
||||
return this.fetchMore({ page })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
|
@ -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: 'udp-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 { 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: 'PageUDPServices',
|
||||
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('udp', { allServices: 'allServices' })
|
||||
},
|
||||
watch: {
|
||||
'status' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'filter' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortBy' () {
|
||||
this.refreshAll()
|
||||
},
|
||||
'sortDir' () {
|
||||
this.refreshAll()
|
||||
}
|
||||
},
|
||||
beforeUnmount () {
|
||||
this.$store.commit('udp/getAllServicesClear')
|
||||
},
|
||||
methods: {
|
||||
...mapActions('udp', { 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>
|
||||
80
webui/src/pages/udp/UdpRouter.spec.tsx
Normal file
80
webui/src/pages/udp/UdpRouter.spec.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { UdpRouterRender } from './UdpRouter'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpRouterPage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender name="mock-router" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender name="mock-router" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpRouterRender 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-udp'],
|
||||
service: 'udp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'udp-all@docker',
|
||||
provider: 'docker',
|
||||
middlewares: undefined,
|
||||
hasValidMiddlewares: undefined,
|
||||
entryPointsData: [
|
||||
{
|
||||
address: ':443',
|
||||
name: 'web-secured',
|
||||
},
|
||||
{
|
||||
address: ':8000',
|
||||
name: 'web',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const { getByTestId } = renderWithProviders(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
<UdpRouterRender 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('udp-all@docker')
|
||||
expect(routerStructure.innerHTML).toContain('udp-all</span>')
|
||||
expect(routerStructure.innerHTML).toContain('UDP 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('udp-all@docker')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('Entrypoints')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web</')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('web-secured')
|
||||
expect(routerDetailsPanel?.innerHTML).toContain('udp-all</')
|
||||
|
||||
expect(getByTestId('/udp/services/udp-all@docker')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
79
webui/src/pages/udp/UdpRouter.tsx
Normal file
79
webui/src/pages/udp/UdpRouter.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { Flex, styled, Text } from '@traefiklabs/faency'
|
||||
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'
|
||||
|
||||
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} />
|
||||
</SpacedColumns>
|
||||
)
|
||||
|
||||
type UdpRouterRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const UdpRouterRender = ({ data, error, name }: UdpRouterRenderProps) => {
|
||||
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="udp" />
|
||||
<RouterDetail data={data} />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const UdpRouter = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'routers', 'udp')
|
||||
|
||||
return <UdpRouterRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default UdpRouter
|
||||
85
webui/src/pages/udp/UdpRouters.spec.tsx
Normal file
85
webui/src/pages/udp/UdpRouters.spec.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { makeRowRender, UdpRouters as UdpRoutersPage, UdpRoutersRender } from './UdpRouters'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpRoutersPage />', () => {
|
||||
it('should render the routers list', () => {
|
||||
const pages = [
|
||||
{
|
||||
entryPoints: ['web-udp'],
|
||||
service: 'udp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'udp-all@docker00',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-udp'],
|
||||
service: 'udp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'disabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'udp-all@docker01',
|
||||
provider: 'docker',
|
||||
},
|
||||
{
|
||||
entryPoints: ['web-udp'],
|
||||
service: 'udp-all',
|
||||
rule: 'HostSNI(`*`)',
|
||||
status: 'enabled',
|
||||
using: ['web-secured', 'web'],
|
||||
name: 'udp-all@docker02',
|
||||
provider: 'docker',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<UdpRoutersPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toIncludeMultiple(['web-udp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('udp-all@docker00')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('udp-all')
|
||||
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).toIncludeMultiple(['web-udp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('udp-all@docker01')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[1].innerHTML).toContain('udp-all')
|
||||
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).toIncludeMultiple(['web-udp'])
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('udp-all@docker02')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[2].innerHTML).toContain('udp-all')
|
||||
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(
|
||||
<UdpRoutersRender
|
||||
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')
|
||||
})
|
||||
})
|
||||
127
webui/src/pages/udp/UdpRouters.tsx
Normal file
127
webui/src/pages/udp/UdpRouters.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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 { 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 UdpRoutersRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/udp/routers/${row.name}`}>
|
||||
<AriaTd>
|
||||
<Tooltip label={row.status}>
|
||||
<Box css={{ width: '32px', height: '32px' }}>
|
||||
<ResourceStatus status={row.status} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</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 UdpRoutersRenderRow
|
||||
}
|
||||
|
||||
export const UdpRoutersRender = ({
|
||||
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="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" css={{ width: '60px' }} 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 UdpRouters = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/udp/routers',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="UDP Routers">
|
||||
<TableFilter />
|
||||
<UdpRoutersRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
163
webui/src/pages/udp/UdpService.spec.tsx
Normal file
163
webui/src/pages/udp/UdpService.spec.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { UdpServiceRender } from './UdpService'
|
||||
|
||||
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpServicePage />', () => {
|
||||
it('should render the error message', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender name="mock-service" data={undefined} error={new Error('Test error')} />,
|
||||
)
|
||||
expect(getByTestId('error-text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the skeleton', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender name="mock-service" data={undefined} error={undefined} />,
|
||||
)
|
||||
expect(getByTestId('skeleton')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the not found page', () => {
|
||||
const { getByTestId } = renderWithProviders(
|
||||
<UdpServiceRender 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
|
||||
<UdpServiceRender 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
|
||||
<UdpServiceRender 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
|
||||
<UdpServiceRender 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/udp/UdpService.tsx
Normal file
66
webui/src/pages/udp/UdpService.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 UdpServiceRenderProps = {
|
||||
data?: ResourceDetailDataType
|
||||
error?: Error
|
||||
name: string
|
||||
}
|
||||
|
||||
export const UdpServiceRender = ({ data, error, name }: UdpServiceRenderProps) => {
|
||||
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="udp" />
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
export const UdpService = () => {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data, error } = useResourceDetail(name!, 'services', 'udp')
|
||||
return <UdpServiceRender data={data} error={error} name={name!} />
|
||||
}
|
||||
|
||||
export default UdpService
|
||||
82
webui/src/pages/udp/UdpServices.spec.tsx
Normal file
82
webui/src/pages/udp/UdpServices.spec.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { makeRowRender, UdpServices as UdpServicesPage, UdpServicesRender } from './UdpServices'
|
||||
|
||||
import * as useFetchWithPagination from 'hooks/use-fetch-with-pagination'
|
||||
import { useFetchWithPaginationMock } from 'utils/mocks'
|
||||
import { renderWithProviders } from 'utils/test'
|
||||
|
||||
describe('<UdpServicesPage />', () => {
|
||||
it('should render the services list', () => {
|
||||
const pages = [
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'enabled',
|
||||
usedBy: ['udp-all@docker'],
|
||||
name: 'udp-all@docker00',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'disabled',
|
||||
usedBy: ['udp-all@docker'],
|
||||
name: 'udp-all@docker01',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
{
|
||||
loadBalancer: { terminationDelay: 10, servers: [{ address: '10.0.1.14:8080' }] },
|
||||
status: 'enabled',
|
||||
usedBy: ['udp-all@docker'],
|
||||
name: 'udp-all@docker02',
|
||||
provider: 'docker',
|
||||
type: 'loadbalancer',
|
||||
},
|
||||
].map(makeRowRender())
|
||||
const mock = vi
|
||||
.spyOn(useFetchWithPagination, 'default')
|
||||
.mockImplementation(() => useFetchWithPaginationMock({ pages }))
|
||||
|
||||
const { container, getByTestId } = renderWithProviders(<UdpServicesPage />)
|
||||
|
||||
expect(mock).toHaveBeenCalled()
|
||||
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)
|
||||
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('testid="enabled"')
|
||||
expect(tbody.querySelectorAll('a[role="row"]')[0].innerHTML).toContain('udp-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('udp-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('udp-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(
|
||||
<UdpServicesRender
|
||||
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/udp/UdpServices.tsx
Normal file
124
webui/src/pages/udp/UdpServices.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 UdpServicesRenderRow = (row) => (
|
||||
<ClickableRow key={row.name} to={`/udp/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 UdpServicesRenderRow
|
||||
}
|
||||
|
||||
export const UdpServicesRender = ({
|
||||
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 UdpServices = () => {
|
||||
const renderRow = makeRowRender()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const query = useMemo(() => searchParamsToState(searchParams), [searchParams])
|
||||
const { pages, pageCount, isLoadingMore, isReachingEnd, loadMore, error, isEmpty } = useFetchWithPagination(
|
||||
'/udp/services',
|
||||
{
|
||||
listContextKey: JSON.stringify(query),
|
||||
renderRow,
|
||||
renderLoader: () => null,
|
||||
query,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
<Page title="UDP Services">
|
||||
<TableFilter />
|
||||
<UdpServicesRender
|
||||
error={error}
|
||||
isEmpty={isEmpty}
|
||||
isLoadingMore={isLoadingMore}
|
||||
isReachingEnd={isReachingEnd}
|
||||
loadMore={loadMore}
|
||||
pageCount={pageCount}
|
||||
pages={pages}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
4
webui/src/pages/udp/index.ts
Normal file
4
webui/src/pages/udp/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { UdpRouter } from './UdpRouter'
|
||||
export { UdpRouters } from './UdpRouters'
|
||||
export { UdpService } from './UdpService'
|
||||
export { UdpServices } from './UdpServices'
|
||||
Loading…
Add table
Add a link
Reference in a new issue