1
0
Fork 0

Migrate Traefik Proxy dashboard UI to React

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

View file

@ -0,0 +1,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&apos;m sorry, nothing around here...</Text>
</Box>
<Button variant="primary" onClick={() => navigate(-1)}>
Go back
</Button>
</Flex>
</Page>
)
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>
)
}

View file

@ -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>

View 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 &gt; child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; sibling &gt; negative Grand Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; sibling &gt; positive Grand Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; string Child')
expect(middlewareCard.innerHTML).toContain('jwtAuth &gt; array Child')
const childSpans = Array.from(middlewareCard.querySelectorAll('span')).filter((span) =>
['0', '1', '2', '3', '123'].includes(span.innerHTML),
)
expect(childSpans.length).toBe(7)
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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()
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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"]')
})
})

View 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

View 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')
})
})

View 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>
)
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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
View 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 }

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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')
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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()
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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"]')
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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'

View file

@ -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>

View file

@ -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>

View 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()
})
})

View 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

View 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')
})
})

View 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>
)
}

View 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"]')
})
})

View 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

View 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')
})
})

View 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>
)
}

View file

@ -0,0 +1,4 @@
export { UdpRouter } from './UdpRouter'
export { UdpRouters } from './UdpRouters'
export { UdpService } from './UdpService'
export { UdpServices } from './UdpServices'