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

10
webui/src/App.spec.tsx Normal file
View file

@ -0,0 +1,10 @@
import App from './App'
import { render } from 'utils/test'
describe('<App />', () => {
test('renders without crashing the initial page (dashboard)', () => {
const { getByTestId } = render(<App />)
expect(getByTestId('proxy-main-nav')).toBeInTheDocument()
})
})

113
webui/src/App.tsx Normal file
View file

@ -0,0 +1,113 @@
import { Box, darkTheme, FaencyProvider, lightTheme } from '@traefiklabs/faency'
import { Suspense, useEffect } from 'react'
import { Helmet, HelmetProvider } from 'react-helmet-async'
import { HashRouter, Navigate, Route, Routes as RouterRoutes, useLocation } from 'react-router-dom'
import { SWRConfig } from 'swr'
import Page from './layout/Page'
import fetch from './libs/fetch'
import { useIsDarkMode } from 'hooks/use-theme'
import useVersion from 'hooks/use-version'
import ErrorSuspenseWrapper from 'layout/ErrorSuspenseWrapper'
import { Dashboard, HTTPPages, NotFound, TCPPages, UDPPages } from 'pages'
import { DashboardSkeleton } from 'pages/dashboard/Dashboard'
export const LIGHT_THEME = lightTheme('blue')
export const DARK_THEME = darkTheme('blue')
// TODO: Restore the loader.
const PageLoader = () => (
<Page>
<Box css={{ position: 'absolute', top: 0, left: 0, right: 0 }}>{/*<Loading />*/}</Box>
</Page>
)
const ScrollToTop = () => {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo(0, 0)
}, [pathname])
return null
}
export const Routes = () => {
const { showHubButton } = useVersion()
return (
<Suspense fallback={<PageLoader />}>
{showHubButton && (
<Helmet>
<script src="https://traefik.github.io/traefiklabs-hub-button-app/main-v1.js"></script>
</Helmet>
)}
<RouterRoutes>
<Route
path="/"
element={
<ErrorSuspenseWrapper suspenseFallback={<DashboardSkeleton />}>
<Dashboard />
</ErrorSuspenseWrapper>
}
/>
<Route path="/http/routers" element={<HTTPPages.HttpRouters />} />
<Route path="/http/services" element={<HTTPPages.HttpServices />} />
<Route path="/http/middlewares" element={<HTTPPages.HttpMiddlewares />} />
<Route path="/tcp/routers" element={<TCPPages.TcpRouters />} />
<Route path="/tcp/services" element={<TCPPages.TcpServices />} />
<Route path="/tcp/middlewares" element={<TCPPages.TcpMiddlewares />} />
<Route path="/udp/routers" element={<UDPPages.UdpRouters />} />
<Route path="/udp/services" element={<UDPPages.UdpServices />} />
<Route path="/http/routers/:name" element={<HTTPPages.HttpRouter />} />
<Route path="/http/services/:name" element={<HTTPPages.HttpService />} />
<Route path="/http/middlewares/:name" element={<HTTPPages.HttpMiddleware />} />
<Route path="/tcp/routers/:name" element={<TCPPages.TcpRouter />} />
<Route path="/tcp/services/:name" element={<TCPPages.TcpService />} />
<Route path="/tcp/middlewares/:name" element={<TCPPages.TcpMiddleware />} />
<Route path="/udp/routers/:name" element={<UDPPages.UdpRouter />} />
<Route path="/udp/services/:name" element={<UDPPages.UdpService />} />
<Route path="/http" element={<Navigate to="/http/routers" replace />} />
<Route path="/tcp" element={<Navigate to="/tcp/routers" replace />} />
<Route path="/udp" element={<Navigate to="/udp/routers" replace />} />
<Route path="*" element={<NotFound />} />
</RouterRoutes>
</Suspense>
)
}
const isDev = import.meta.env.NODE_ENV === 'development'
const App = () => {
const isDarkMode = useIsDarkMode()
useEffect(() => {
if (isDarkMode) {
document.documentElement.classList.remove(LIGHT_THEME)
document.documentElement.classList.add(DARK_THEME)
} else {
document.documentElement.classList.remove(DARK_THEME)
document.documentElement.classList.add(LIGHT_THEME)
}
}, [isDarkMode])
return (
<FaencyProvider>
<HelmetProvider>
<SWRConfig
value={{
revalidateOnFocus: !isDev,
fetcher: fetch,
}}
>
<HashRouter basename={import.meta.env.VITE_APP_BASE_URL || ''}>
<ScrollToTop />
<Routes />
</HashRouter>
</SWRConfig>
</HelmetProvider>
</FaencyProvider>
)
}
export default App

View file

@ -1,44 +0,0 @@
<template>
<div id="q-app">
<router-view />
</div>
</template>
<script>
import { APP } from './_helpers/APP'
import { mapGetters } from 'vuex'
export default {
name: 'App',
computed: {
...mapGetters('core', { coreVersion: 'version' })
},
watch: {
'$q.dark.mode' (val) {
if (val !== null) {
localStorage.setItem('traefik-dark', val)
}
}
},
beforeCreate () {
// Set vue instance
APP.vue = () => this.$root
// debug
console.log('Quasar -> ', this.$q.version)
// Get stored theme or default to 'auto'
const storedTheme = localStorage.getItem('traefik-dark')
if (storedTheme === 'true') {
this.$q.dark.set(true)
} else if (storedTheme === 'false') {
this.$q.dark.set(false)
} else {
this.$q.dark.set('auto')
}
}
}
</script>
<style>
</style>

View file

@ -1,8 +0,0 @@
const APP = {
config: {
env: process.env.APP_ENV,
apiUrl: process.env.APP_API
}
}
export { APP }

View file

@ -1,41 +0,0 @@
import { Notify } from 'quasar'
class Errors {
// Getters
// ------------------------------------------------------------------------
// Public
// ------------------------------------------------------------------------
// Static
// ------------------------------------------------------------------------
static showError (body) {
body = typeof body === 'string' ? JSON.parse(body) : body
Notify.create({
color: 'negative',
position: 'top',
message: body.message, // TODO - get correct error message
icon: 'report_problem'
})
}
static handleResponse (error) {
console.log('handleResponse', error, error.response)
const body = error.response.data
if (error.response.status === 401) {
// TODO - actions...
}
// Avoid to notify when reaching end of an infinite scroll
if (!error.response.data.message.includes('invalid request: page:')) {
Errors.showError(body)
}
return Promise.reject(body)
}
// Static Private
// ------------------------------------------------------------------------
}
export default Errors

View file

@ -1,132 +0,0 @@
import { getProperty } from 'dot-prop'
class Helps {
// Getters
// ------------------------------------------------------------------------
// Public
// ------------------------------------------------------------------------
// Static
// ------------------------------------------------------------------------
static get (obj, prop, def = undefined) {
return getProperty(obj, prop, def)
}
static hasIn (obj, prop) {
return Helps.get(obj, prop) !== undefined && Helps.get(obj, prop) !== null
}
static toggleBodyClass (addRemoveClass, className) {
const el = document.body
if (addRemoveClass === 'addClass') {
el.classList.add(className)
} else {
el.classList.remove(className)
}
}
static getName (obj, val) {
let name = ''
for (let i = 0; i < obj.length; i += 1) {
if (obj[i].value === val || obj[i].iso2 === val) {
name = obj[i].name
}
}
return name
}
static removeEmptyObjects (objects) {
Object.entries(objects)
.filter(item => item[1] !== '')
.reduce((acc, item) => {
acc[item[0]] = item[1]
return acc
}, {})
}
// Helps -> Numbers
// ------------------------------------------------------------------------
static getPercent (value, total) {
return (value * 100) / total
}
// Helps -> Array
// ------------------------------------------------------------------------
// Add or remove values
static toggleArray (array, value) {
if (array.includes(value)) {
array.splice(array.indexOf(value), 1)
} else {
array.push(value)
}
}
// Helps -> Strings
// ------------------------------------------------------------------------
// Basename
static basename (path, suffix) {
let b = path
const lastChar = b.charAt(b.length - 1)
if (lastChar === '/' || lastChar === '\\') {
b = b.slice(0, -1)
}
// eslint-disable-next-line no-useless-escape
b = b.replace(/^.*[\/\\]/g, '')
if (typeof suffix === 'string' && b.substr(b.length - suffix.length) === suffix) {
b = b.substr(0, b.length - suffix.length)
}
return b
}
// Slug
static slug (str) {
str = str.replace(/^\s+|\s+$/g, '') // trim
str = str.toLowerCase()
// remove accents, swap ñ for n, etc
const from = 'ãàáäâẽèéëêìíïîõòóöôùúüûñç·/_,:;'
const to = 'aaaaaeeeeeiiiiooooouuuunc------'
for (let i = 0, l = from.length; i < l; i += 1) {
str = str.replace(new RegExp(from.charAt(i), 'g'), to.charAt(i))
}
str = str.replace(/[^a-z0-9 -]/g, '') // remove invalid chars
.replace(/\s+/g, '-') // collapse whitespace and replace by -
.replace(/-+/g, '-') // collapse dashes
return str
}
// Capitalize first letter
static capFirstLetter (string) {
return string.charAt(0).toUpperCase() + string.slice(1)
}
// Repeat
static repeat (string, times) {
return new Array(times + 1).join(string)
}
// Get Attribute
static getAttribute (string, key) {
const _key = `${key}="`
const start = string.indexOf(_key) + _key.length
const end = string.indexOf('"', start + 1)
return string.substring(start, end)
}
// Private
// ------------------------------------------------------------------------
}
export default Helps

View file

@ -1,45 +0,0 @@
import { setProperty, getProperty } from 'dot-prop'
export const withPagination = (type, opts = {}) => (state, data) => {
const { isSameContext, statePath } = opts
const currentState = getProperty(state, statePath)
let newState
switch (type) {
case 'request':
newState = {
...currentState,
loading: true
}
break
case 'success': {
const { body, page } = data
newState = {
...currentState,
items: [
...(isSameContext && currentState.items && page !== 1 ? currentState.items : []),
...(body.data || [])
],
currentPage: page,
total: isSameContext && currentState.items && page !== 1
? body.total + currentState.total
: body.total,
loading: false
}
break
}
case 'failure':
newState = {
...currentState,
loading: false,
error: data,
endReached: data.message.includes('invalid request: page:')
}
break
}
if (newState) {
setProperty(state, statePath, newState)
}
}

View file

@ -1,44 +0,0 @@
import { APP } from '../_helpers/APP'
import Helps from '../_helpers/Helps'
const Boot = {
install (Vue, options) {
Vue.mixin({
filters: {
capFirstLetter (value) {
return Helps.capFirstLetter(value)
}
},
data () {
return {
}
},
computed: {
api () {
return APP.config.apiUrl
},
env () {
return APP.config.env
},
platformUrl () {
return APP.config.platformUrl
},
appThumbStyle () {
return {
right: '2px',
borderRadius: '2px',
backgroundColor: '#dcdcdc',
width: '6px',
opacity: 0.75
}
}
},
created () {
},
methods: {
}
})
}
}
export default Boot

View file

@ -1,175 +0,0 @@
import { getProperty } from 'dot-prop'
import { QChip } from 'quasar'
import Chips from '../components/_commons/Chips.vue'
import ProviderIcon from '../components/_commons/ProviderIcon.vue'
import AvatarState from '../components/_commons/AvatarState.vue'
import TLSState from '../components/_commons/TLSState.vue'
const allColumns = [
{
name: 'status',
required: true,
label: 'Status',
align: 'left',
sortable: true,
fieldToProps: row => ({
state: row.status === 'enabled' ? 'positive' : 'negative'
}),
component: AvatarState
},
{
name: 'tls',
align: 'left',
label: 'TLS',
sortable: false,
fieldToProps: row => ({ isTLS: row.tls }),
component: TLSState
},
{
name: 'rule',
align: 'left',
label: 'Rule',
sortable: true,
component: QChip,
fieldToProps: () => ({ class: 'app-chip app-chip-rule', dense: true }),
content: row => row.rule
},
{
name: 'entryPoints',
align: 'left',
label: 'Entrypoints',
sortable: true,
component: Chips,
fieldToProps: row => ({
classNames: 'app-chip app-chip-entry-points',
dense: true,
list: row.entryPoints
})
},
{
name: 'name',
align: 'left',
label: 'Name',
sortable: true,
component: QChip,
fieldToProps: () => ({ class: 'app-chip app-chip-name', dense: true }),
content: row => row.name
},
{
name: 'type',
align: 'left',
label: 'Type',
sortable: true,
component: QChip,
fieldToProps: () => ({
class: 'app-chip app-chip-entry-points',
dense: true
}),
content: row => row.type
},
{
name: 'servers',
align: 'right',
label: 'Servers',
sortable: true,
fieldToProps: () => ({ class: 'servers-label' }),
content: function (value) {
if (value.loadBalancer && value.loadBalancer.servers) {
return value.loadBalancer.servers.length
}
return 0
}
},
{
name: 'service',
align: 'left',
label: 'Service',
component: QChip,
sortable: true,
fieldToProps: () => ({ class: 'app-chip app-chip-service', dense: true }),
content: row => row.service
},
{
name: 'provider',
align: 'center',
label: 'Provider',
sortable: true,
fieldToProps: row => ({ name: row.provider }),
component: ProviderIcon
},
{
name: 'priority',
align: 'left',
label: 'Priority',
sortable: true,
component: QChip,
fieldToProps: () => ({ class: 'app-chip app-chip-accent', dense: true }),
content: row => {
return {
short: String(row.priority).length > 10 ? String(row.priority).substring(0, 10) + '...' : row.priority,
long: row.priority
}
}
}
]
const columnsByResource = {
routers: [
'status',
'rule',
'entryPoints',
'name',
'service',
'tls',
'provider',
'priority'
],
udpRouters: ['status', 'entryPoints', 'name', 'service', 'provider'],
services: ['status', 'name', 'type', 'servers', 'provider'],
middlewares: ['status', 'name', 'type', 'provider']
}
const propsByType = {
'http-routers': {
columns: columnsByResource.routers
},
'tcp-routers': {
columns: columnsByResource.routers
},
'udp-routers': {
columns: columnsByResource.udpRouters
},
'http-services': {
columns: columnsByResource.services
},
'tcp-services': {
columns: columnsByResource.services
},
'udp-services': {
columns: columnsByResource.services
},
'http-middlewares': {
columns: columnsByResource.middlewares
},
'tcp-middlewares': {
columns: columnsByResource.middlewares
}
}
const GetTablePropsMixin = {
methods: {
getTableProps ({ type }) {
return {
onRowClick: row =>
this.$router.push({
path: `/${type.replace('-', '/', 'gi')}/${encodeURIComponent(row.name)}`
}),
columns: allColumns.filter(c =>
getProperty(propsByType, `${type}.columns`, []).includes(c.name)
)
}
}
}
}
export default GetTablePropsMixin

View file

@ -1,74 +0,0 @@
import { getProperty } from 'dot-prop'
export default function PaginationMixin (opts = {}) {
const { pollingIntervalTime, rowsPerPage = 10 } = opts
let listLength = 0
let currentPage = 1
let currentLimit = rowsPerPage
return {
methods: {
fetchWithInterval () {
this.initFetch({ limit: listLength })
this.pollingInterval = setInterval(
() => {
this.fetchMore({
limit: Math.ceil(listLength / rowsPerPage) * rowsPerPage, // round up to multiple of rowsPerPage
refresh: true
})
},
pollingIntervalTime
)
},
fetchMore ({ page = 1, limit = rowsPerPage, refresh, ...params } = {}) {
if (page === currentPage && limit === currentLimit && !refresh) {
return Promise.resolve()
}
currentPage = page
currentLimit = limit || rowsPerPage
const fetchMethod = getProperty(this, opts.fetchMethod)
return fetchMethod({
...params,
page,
limit: limit || rowsPerPage
}).then(res => {
listLength = page > 1
? listLength += res.data.length
: res.data.length
})
},
initFetch (params) {
const scrollerRef = getProperty(this.$refs, opts.scrollerRef)
if (scrollerRef) {
scrollerRef.stop()
scrollerRef.reset()
}
return this.fetchMore({
page: 1,
refresh: true,
...params
}).then(() => {
if (scrollerRef) {
scrollerRef.resume()
scrollerRef.poll()
}
})
}
},
mounted () {
if (pollingIntervalTime) {
this.fetchWithInterval()
} else {
this.fetchMore()
}
},
beforeUnmount () {
clearInterval(this.pollingInterval)
}
}
}

View file

@ -1,24 +0,0 @@
import { APP } from '../_helpers/APP'
const apiBase = ''
function getOverview () {
return APP.api.get(`${apiBase}/overview`)
.then(body => {
console.log('Success -> CoreService -> getOverview', body.data)
return body.data
})
}
function getVersion () {
return APP.api.get(`${apiBase}/version`)
.then(body => {
console.log('Success -> CoreService -> getVersion', body.data)
return body.data
})
}
export default {
getOverview,
getVersion
}

View file

@ -1,24 +0,0 @@
import { APP } from '../_helpers/APP'
const apiBase = '/entrypoints'
function getAll () {
return APP.api.get(`${apiBase}`)
.then(body => {
console.log('Success -> EntrypointsService -> getAll', body.data)
return body.data
})
}
function getByName (name) {
return APP.api.get(`${apiBase}/${name}`)
.then(body => {
console.log('Success -> EntrypointsService -> getByName', body.data)
return body.data
})
}
export default {
getAll,
getByName
}

View file

@ -1,67 +0,0 @@
import { APP } from '../_helpers/APP'
import { getTotal } from './utils'
const apiBase = '/http'
function getAllRouters (params) {
return APP.api.get(`${apiBase}/routers?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}&serviceName=${params.serviceName}&middlewareName=${params.middlewareName}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> HttpService -> getAllRouters', response, response.data)
return { data, total }
})
}
function getRouterByName (name) {
return APP.api.get(`${apiBase}/routers/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> HttpService -> getRouterByName', body.data)
return body.data
})
}
function getAllServices (params) {
return APP.api.get(`${apiBase}/services?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> HttpService -> getAllServices', response.data)
return { data, total }
})
}
function getServiceByName (name) {
return APP.api.get(`${apiBase}/services/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> HttpService -> getServiceByName', body.data)
return body.data
})
}
function getAllMiddlewares (params) {
return APP.api.get(`${apiBase}/middlewares?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> HttpService -> getAllMiddlewares', response.data)
return { data, total }
})
}
function getMiddlewareByName (name) {
return APP.api.get(`${apiBase}/middlewares/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> HttpService -> getMiddlewareByName', body.data)
return body.data
})
}
export default {
getAllRouters,
getRouterByName,
getAllServices,
getServiceByName,
getAllMiddlewares,
getMiddlewareByName
}

View file

@ -1,67 +0,0 @@
import { APP } from '../_helpers/APP'
import { getTotal } from './utils'
const apiBase = '/tcp'
function getAllRouters (params) {
return APP.api.get(`${apiBase}/routers?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}&serviceName=${params.serviceName}&middlewareName=${params.middlewareName}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> TcpService -> getAllRouters', response.data)
return { data, total }
})
}
function getRouterByName (name) {
return APP.api.get(`${apiBase}/routers/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> TcpService -> getRouterByName', body.data)
return body.data
})
}
function getAllServices (params) {
return APP.api.get(`${apiBase}/services?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> TcpService -> getAllServices', response.data)
return { data, total }
})
}
function getServiceByName (name) {
return APP.api.get(`${apiBase}/services/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> TcpService -> getServiceByName', body.data)
return body.data
})
}
function getAllMiddlewares (params) {
return APP.api.get(`${apiBase}/middlewares?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> TcpService -> getAllMiddlewares', response.data)
return { data, total }
})
}
function getMiddlewareByName (name) {
return APP.api.get(`${apiBase}/middlewares/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> TcpService -> getMiddlewareByName', body.data)
return body.data
})
}
export default {
getAllRouters,
getRouterByName,
getAllServices,
getServiceByName,
getAllMiddlewares,
getMiddlewareByName
}

View file

@ -1,47 +0,0 @@
import { APP } from '../_helpers/APP'
import { getTotal } from './utils'
const apiBase = '/udp'
function getAllRouters (params) {
return APP.api.get(`${apiBase}/routers?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}&serviceName=${params.serviceName}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> UdpService -> getAllRouters', response.data)
return { data, total }
})
}
function getRouterByName (name) {
return APP.api.get(`${apiBase}/routers/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> UdpService -> getRouterByName', body.data)
return body.data
})
}
function getAllServices (params) {
return APP.api.get(`${apiBase}/services?search=${params.query}&status=${params.status}&per_page=${params.limit}&page=${params.page}&sortBy=${params.sortBy}&direction=${params.direction}`)
.then(response => {
const { data = [], headers } = response
const total = getTotal(headers, params)
console.log('Success -> UdpService -> getAllServices', response.data)
return { data, total }
})
}
function getServiceByName (name) {
return APP.api.get(`${apiBase}/services/${encodeURIComponent(name)}`)
.then(body => {
console.log('Success -> UdpService -> getServiceByName', body.data)
return body.data
})
}
export default {
getAllRouters,
getRouterByName,
getAllServices,
getServiceByName
}

View file

@ -1,8 +0,0 @@
export const getTotal = (headers, params) => {
const nextPage = parseInt(headers['x-next-page'], 10) || 1
const hasNextPage = nextPage > 1
return hasNextPage
? (params.page + 1) * params.limit
: params.page * params.limit
}

View file

@ -1,6 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="84" height="24" viewBox="0 0 84 24">
<g fill="none" fill-rule="nonzero">
<path fill="#FFF" d="M7.142 24c-1.241 0-2.23-.406-2.963-1.217-.735-.822-1.102-2.082-1.102-3.781v-9.47H1.101c-.356 0-.631-.088-.826-.263C.092 9.093 0 8.858 0 8.562c0-.285.092-.516.275-.691.184-.186.454-.28.81-.28h1.992l.389-4.669c.022-.35.173-.652.453-.904.292-.252.61-.378.956-.378.324 0 .594.104.81.312.216.197.324.488.324.872v4.768h2.98c.356 0 .62.093.793.279.184.186.276.428.276.723 0 .625-.357.938-1.07.938h-2.98v8.96c0 1.13.157 1.946.47 2.45.314.493.81.74 1.49.74h.179l1.101-.05h.097c.356 0 .637.116.842.346.205.23.308.498.308.805 0 .274-.06.5-.178.674-.119.165-.302.285-.55.362a3.31 3.31 0 0 1-.73.148c-.237.022-.55.033-.939.033h-.956zm7.771 0c-.405 0-.75-.12-1.035-.358-.284-.238-.427-.574-.427-1.007V9.228c0-.434.148-.764.444-.992a1.623 1.623 0 0 1 1.051-.357c.405 0 .75.119 1.035.357.295.228.443.547.443.96l-.016 1.95a4.815 4.815 0 0 1 1.642-2.52c.832-.682 1.829-1.024 2.99-1.024.35 0 .624.13.82.39.209.26.313.564.313.91 0 .326-.093.607-.28.846-.186.227-.454.34-.804.34-1.478 0-2.628.38-3.45 1.139-.81.758-1.215 1.77-1.215 3.038v8.37c0 .433-.153.77-.46 1.007a1.623 1.623 0 0 1-1.05.358zm40.61-.383a1.522 1.522 0 0 1-1.037.383c-.4 0-.745-.128-1.037-.383-.28-.267-.42-.633-.42-1.1V9.578h-2.123c-.357 0-.621-.09-.794-.267a.997.997 0 0 1-.26-.7c0-.288.087-.527.26-.715.173-.19.437-.284.794-.284h2.122V5.496c0-1.987.41-3.397 1.232-4.23C55.08.422 56.215 0 57.662 0h1.977c.313 0 .556.117.729.35.183.222.275.488.275.8 0 .31-.092.577-.275.799-.173.222-.421.333-.745.333h-1.378c-.464 0-.826.044-1.085.133-.26.078-.492.26-.697.55-.194.277-.33.682-.405 1.215-.065.522-.097 1.233-.097 2.132v1.3h2.738c.356 0 .616.094.778.283.173.188.259.427.259.716 0 .644-.346.966-1.037.966h-2.738v12.94c0 .467-.146.833-.438 1.1zm9.557-20.04c-.58 0-1.045-.161-1.396-.485-.35-.335-.525-.766-.525-1.295 0-.529.175-.96.525-1.295.362-.335.832-.502 1.413-.502.57 0 1.03.167 1.38.502.35.324.525.755.525 1.295 0 .529-.175.96-.526 1.295-.35.324-.815.486-1.396.486zM65.056 24a1.49 1.49 0 0 1-1.03-.38c-.282-.263-.424-.637-.424-1.121V9.087c0-.462.147-.825.441-1.089a1.573 1.573 0 0 1 1.062-.396c.392 0 .73.132 1.013.396.294.264.44.627.44 1.09v13.41c0 .463-.152.831-.457 1.106a1.513 1.513 0 0 1-1.045.396zm8.44-.407a1.53 1.53 0 0 1-1.048.39c-.403 0-.747-.124-1.03-.374-.284-.26-.426-.629-.426-1.106V1.464c0-.455.147-.813.442-1.073.305-.26.66-.391 1.063-.391.393 0 .731.13 1.015.39.294.26.442.619.442 1.074V14.66l7.428-6.768c.24-.207.496-.31.77-.31.348 0 .654.136.915.407.273.271.41.575.41.911 0 .304-.132.58-.393.83l-5.94 5.288 6.496 6.802c.24.25.36.531.36.846 0 .347-.142.656-.425.927-.273.271-.59.407-.95.407-.381 0-.708-.152-.981-.456l-7.69-8.135v7.094c0 .466-.153.83-.459 1.09z"/>
<path fill="#4B76DD" d="M28.93 23.993c-1.942-.163-3.427-.734-4.506-1.732-1.1-1.016-1.57-2.34-1.384-3.888.214-1.781 1.083-3.039 2.608-3.773 1.175-.565 2.348-.78 4.257-.781 1.357 0 2.493.108 4.227.401.429.073.8.132.825.132.036 0 .065-.133.144-.664.142-.96.167-1.292.129-1.676-.046-.448-.104-.662-.279-1.009-.45-.895-1.517-1.443-3.232-1.658-.49-.062-1.622-.07-2.114-.016-.823.09-1.282.206-2.306.58-.6.218-.602.218-.841.205-.411-.024-.692-.205-.872-.563-.074-.147-.087-.208-.087-.407 0-.298.08-.456.358-.709.623-.567 1.671-1.05 2.73-1.258.677-.132 1.01-.16 1.903-.159.923.002 1.514.052 2.335.2 1.365.248 2.402.683 3.183 1.338.232.194.582.577.732.8a.433.433 0 0 0 .105.124c.012 0 .087-.082.168-.182C38.197 7.83 40.19 6.998 42.526 7c1.652 0 3.187.408 4.332 1.148.908.587 1.645 1.49 1.943 2.382.171.512.197.701.199 1.444 0 .463-.012.775-.039.92-.268 1.46-.906 2.473-2.013 3.193-.891.58-2.01.92-3.474 1.053-.736.068-2.084.061-2.957-.014-.74-.063-1.65-.175-2.038-.25a34.12 34.12 0 0 0-1.446-.236 22.15 22.15 0 0 0-.118.72c-.097.625-.11.782-.113 1.281-.002.508.006.597.069.832.202.754.648 1.258 1.458 1.648.797.383 1.631.55 2.893.575.559.011.845.003 1.194-.033.847-.087 1.233-.183 2.358-.586.28-.1.567-.191.636-.203.149-.024.426.026.587.106.292.144.537.528.535.84-.001.362-.127.582-.497.868-.883.684-2.01 1.104-3.357 1.25-.48.05-1.576.059-2.106.014-1.49-.126-2.526-.374-3.472-.833-.738-.357-1.301-.8-1.722-1.356a24.69 24.69 0 0 0-.195-.255 1.301 1.301 0 0 0-.153.175 6.104 6.104 0 0 1-1.423 1.256c-.763.482-1.859.85-2.967.997-.331.044-1.428.08-1.71.057zm1.904-2.08c.906-.195 1.712-.635 2.298-1.253.534-.563.972-1.408 1.213-2.338.136-.525.362-1.985.312-2.015-.02-.012-.14-.03-.266-.042a31.364 31.364 0 0 1-.799-.092c-1.86-.234-3.002-.325-3.642-.29-1.217.066-1.713.17-2.443.51-.374.173-.633.362-.837.61-.293.358-.47.713-.594 1.191-.09.348-.099 1.259-.015 1.552.14.495.307.775.681 1.145.513.508 1.19.846 2.029 1.015.429.087.416.086 1.134.076.521-.008.716-.022.93-.068zm11.857-6.845c.757-.065 1.222-.18 1.794-.44.476-.218.752-.44 1.014-.816.385-.552.535-1.075.531-1.852-.003-.554-.05-.784-.247-1.182-.44-.888-1.47-1.524-2.822-1.741-.338-.055-1.243-.045-1.59.016-.664.119-1.227.334-1.723.66-.916.604-1.549 1.513-1.9 2.734-.14.48-.176.663-.3 1.49l-.113.762.152.017c.46.05 1.498.172 2.101.247.38.048.845.099 1.034.113.549.042 1.531.038 2.069-.008z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5 KiB

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="88" height="96" viewBox="0 0 88 96">
<g fill="none" fill-rule="nonzero" stroke="#7A7A7A" stroke-width="4">
<path fill="#3D3D3D" d="M41.05 42.807zm0 0l-.006.003 1.917 3.508-1.876-3.532c1.866-1.05 4.007-1.044 5.82-.006 8.058 4.62 22.413 13.097 37.112 21.84C85.14 65.299 86 66.39 86 68c0 1.612-.866 2.71-1.993 3.383-15.424 9.173-29.305 17.368-37.1 21.836a5.813 5.813 0 0 1-5.812 0C33.322 88.765 19.88 80.83 3.983 71.38 2.86 70.7 2 69.61 2 67.999c0-1.615.869-2.72 1.993-3.382a8052.42 8052.42 0 0 1 17.004-10.023l.05-.03a33723.823 33723.823 0 0 1 18.288-10.75l1.27-.746.334-.196.085-.05.022-.013.004-.002z"/>
<path fill="#1D1D1D" stroke-dasharray="0,10" stroke-linecap="round" d="M4 48c0-.96.707-1.483 1.012-1.662 9.254-5.48 37.058-21.81 37.054-21.809 1.274-.717 2.664-.69 3.844-.014 10.211 5.854 30.961 18.182 37.074 21.819.305.183 1.016.71 1.016 1.666 0 .955-.71 1.482-1.016 1.664-7.914 4.707-27.37 16.26-37.07 21.818a3.813 3.813 0 0 1-3.824.002c-9.703-5.56-29.16-17.113-37.074-21.818C4.71 49.482 4 48.955 4 48z"/>
<path fill="#3D3D3D" d="M41.05 2.807zm0 0l-.006.003 1.917 3.508-1.876-3.532c1.866-1.05 4.007-1.044 5.82-.006 8.058 4.62 22.413 13.097 37.112 21.84C85.14 25.299 86 26.39 86 28c0 1.612-.866 2.71-1.993 3.383-15.424 9.173-29.305 17.368-37.1 21.836a5.813 5.813 0 0 1-5.812 0C33.322 48.765 19.88 40.83 3.983 31.38 2.86 30.7 2 29.61 2 27.999c0-1.615.869-2.72 1.993-3.382a8052.42 8052.42 0 0 1 17.004-10.023l.05-.03a33723.375 33723.375 0 0 1 18.288-10.75l1.27-.746.334-.196.085-.05.022-.013.004-.002z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="88" height="96" viewBox="0 0 88 96">
<g fill="none" fill-rule="nonzero" stroke="#DCDCDC" stroke-width="4">
<path fill="#F2F3F5" d="M41.05 42.807zm0 0l-.006.003 1.917 3.508-1.876-3.532c1.866-1.05 4.007-1.044 5.82-.006 8.058 4.62 22.413 13.097 37.112 21.84C85.14 65.299 86 66.39 86 68c0 1.612-.866 2.71-1.993 3.383-15.424 9.173-29.305 17.368-37.1 21.836a5.813 5.813 0 0 1-5.812 0C33.322 88.765 19.88 80.83 3.983 71.38 2.86 70.7 2 69.61 2 67.999c0-1.615.869-2.72 1.993-3.382a8052.42 8052.42 0 0 1 17.004-10.023l.05-.03a33723.823 33723.823 0 0 1 18.288-10.75l1.27-.746.334-.196.085-.05.022-.013.004-.002z"/>
<path fill="#FFF" stroke-dasharray="0,10" stroke-linecap="round" d="M4 48c0-.96.707-1.483 1.012-1.662 9.254-5.48 37.058-21.81 37.054-21.809 1.274-.717 2.664-.69 3.844-.014 10.211 5.854 30.961 18.182 37.074 21.819.305.183 1.016.71 1.016 1.666 0 .955-.71 1.482-1.016 1.664-7.914 4.707-27.37 16.26-37.07 21.818a3.813 3.813 0 0 1-3.824.002c-9.703-5.56-29.16-17.113-37.074-21.818C4.71 49.482 4 48.955 4 48z"/>
<path fill="#F2F3F5" d="M41.05 2.807zm0 0l-.006.003 1.917 3.508-1.876-3.532c1.866-1.05 4.007-1.044 5.82-.006 8.058 4.62 22.413 13.097 37.112 21.84C85.14 25.299 86 26.39 86 28c0 1.612-.866 2.71-1.993 3.383-15.424 9.173-29.305 17.368-37.1 21.836a5.813 5.813 0 0 1-5.812 0C33.322 48.765 19.88 40.83 3.983 31.38 2.86 30.7 2 29.61 2 27.999c0-1.615.869-2.72 1.993-3.382a8052.42 8052.42 0 0 1 17.004-10.023l.05-.03a33723.375 33723.375 0 0 1 18.288-10.75l1.27-.746.334-.196.085-.05.022-.013.004-.002z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,90 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="259px" height="296px" viewBox="0 0 259 296" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: sketchtool 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
<title>35600C78-FD43-44A0-A2D3-2D9D8078107C@3x</title>
<desc>Created with sketchtool.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Traefik---Bandeau-top" transform="translate(-271.000000, -208.000000)">
<g id="Group-5" transform="translate(-6.000000, 60.000000)">
<g id="Group-4" transform="translate(277.000000, 148.000000)">
<g id="traefik.logo.bright">
<path d="M39.8037726,116.53974 C39.8037726,116.53974 60.9593043,98.1480553 123.573394,98.1480553 C181.014826,98.1480553 197.157241,111.161081 213.552404,116.53974 L128.016891,158.279645 L39.8037726,116.53974 Z" id="path3156" fill="#C9781F" fill-rule="nonzero"></path>
<g id="g3158" transform="translate(28.709554, 26.010842)">
<path d="M2.16336011,145.886272 C2.5509667,153.86447 10.5317151,150.173212 14.1468181,147.678793 C17.5782604,145.310847 18.5815419,147.279116 18.8825264,142.60564 C19.0798932,139.534154 19.4346053,136.462668 19.2597164,133.37859 C14.0711608,132.908833 8.42701681,134.142902 4.17321267,137.229171 C1.98244049,138.819664 -2.12607971,143.894459 2.16336011,145.886272" id="path3160" fill="#F6D2A2" fill-rule="nonzero"></path>
<path d="M2.16336011,145.886272 C3.3250834,145.45484 4.55588509,145.148239 5.34425606,144.099225" id="path3162" fill="#C6B198" fill-rule="nonzero"></path>
<path d="M20.6072935,50.8415775 C-9.97579844,42.2529146 12.7652501,3.39834453 37.3242667,19.3799261 L20.6072935,50.8415775 Z" id="path3164" fill="#37ABC8" fill-rule="nonzero"></path>
<path d="M156.4878,16.4639308 C180.695394,-0.516294328 202.570769,37.7940588 174.437219,47.4333779 L156.4878,16.4639308 Z" id="path3166" fill="#37ABC8" fill-rule="nonzero"></path>
<path d="M161.694995,244.076364 C167.84407,247.876028 179.083016,259.364261 169.858856,264.935093 C160.995988,273.039217 156.035501,256.039829 148.256505,253.684476 C151.606808,249.141305 155.852936,244.998357 161.694995,244.076364 Z" id="path3168" fill="#F6D2A2" fill-rule="nonzero"></path>
<path d="M169.858856,264.934546 C168.491543,262.218936 168.032116,259.049995 165.74924,256.861082" id="path3170"></path>
<path d="M51.8422442,257.059825 C44.625196,258.175087 40.562728,264.677767 34.5463282,267.979751 C28.876965,271.355648 26.7081225,266.898983 26.1971616,265.993962 C25.3084625,265.589358 25.3830233,266.370643 24.0113236,264.991486 C18.7553344,256.707781 29.4877058,250.652957 35.0759292,246.537933 C42.859859,244.966055 47.73811,251.705256 51.8422442,257.059825 Z" id="path3172" fill="#F6D2A2" fill-rule="nonzero"></path>
<path d="M26.1977098,265.993414 C26.4723787,262.836518 28.9794861,260.722066 30.1730074,258.052446" id="path3174"></path>
<path d="M21.8835992,40.109992 C17.8485433,37.978567 14.8885886,35.0839242 17.3567709,30.4230402 C19.6418406,26.1087249 23.8868728,26.5768391 27.9219287,28.7082641 L21.8835992,40.109992 Z" id="path3176" fill="#077E91" fill-rule="nonzero"></path>
<path d="M168.062818,36.0579302 C172.097874,33.9265051 175.057829,31.0318624 172.589646,26.3709784 C170.304577,22.0561156 166.060093,22.5247773 162.024488,24.6562023 L168.062818,36.0579302 Z" id="path3178" fill="#077E91" fill-rule="nonzero"></path>
<path d="M195.83727,144.872845 C195.449663,152.851044 187.468915,149.159786 183.853812,146.665367 C180.42237,144.297421 179.419088,146.26569 179.118104,141.592761 C178.920737,138.521275 178.566025,135.44979 178.740913,132.365711 C183.929469,131.895955 189.573613,133.130024 193.827417,136.216292 C196.018189,137.806238 200.12671,142.881581 195.83727,144.872845" id="path3180" fill="#F6D2A2" fill-rule="nonzero"></path>
<path d="M195.83727,144.872845 C194.675546,144.441414 193.444745,144.134813 192.656374,143.085799" id="path3182" fill="#C6B198" fill-rule="nonzero"></path>
<g id="g3184" transform="translate(15.350756, 0.000000)" fill="#37ABC8" fill-rule="nonzero">
<path d="M80.5848901,0.325763653 C111.202521,0.325763653 139.904597,4.69866164 154.867198,34.0748782 C168.296917,67.0021922 163.521187,102.508788 165.569417,137.308011 C167.328175,167.189024 171.228363,201.710117 157.347442,229.400575 C142.745035,258.533153 106.256288,265.861466 76.1934774,264.787815 C52.580177,263.944115 24.0551829,256.250617 10.7285337,234.705869 C-4.90675951,209.430442 2.49559433,171.847171 3.63100204,143.722544 C4.97638615,110.407598 -5.40511084,76.9919126 5.55423247,44.3920047 C16.923112,10.5755474 47.5851507,2.93296788 80.5848901,0.325763653" id="path3186"></path>
</g>
<path d="M101.340757,41.373079 C106.837972,73.285872 159.072757,64.8483196 151.570075,32.6623231 C144.842607,3.80021096 99.514017,11.7893592 101.340757,41.373079" id="path3188" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M38.9207454,46.5343798 C46.0456891,74.3173656 90.5870042,67.2042205 88.9082893,39.3352768 C86.8967921,5.95736859 32.1088474,12.3981814 38.9207454,46.5343798" id="path3190" fill="#FFFFFF" fill-rule="nonzero"></path>
<path d="M111.481028,78.5725507 C111.50515,82.8375908 112.491437,87.0057231 111.63947,91.7936275 C110.578623,93.7952946 108.498595,94.0071779 106.70475,94.8158382 C104.224506,94.4271118 102.138996,92.8010311 101.143938,90.4840029 C100.50743,85.44589 101.381327,80.5681953 101.541962,75.5284399 L111.481028,78.5725507 Z" id="path3192" fill="#FFFFFF" fill-rule="nonzero"></path>
<g id="g3194" transform="translate(43.311062, 33.397618)" fill-rule="nonzero">
<ellipse id="ellipse3196" fill="#000000" cx="7.89466904" cy="8.60563" rx="7.5213222" ry="8.13587881"></ellipse>
<ellipse id="ellipse3198" fill="#FFFFFF" cx="11.3157001" cy="10.4414149" rx="1.77301232" ry="2.06791482"></ellipse>
</g>
<g id="g3200" transform="translate(106.358809, 31.755112)" fill-rule="nonzero">
<ellipse id="ellipse3202" fill="#000000" cx="7.86616049" cy="8.22183661" rx="7.39632319" ry="8.13587881"></ellipse>
<ellipse id="ellipse3204" fill="#FFFFFF" cx="11.2307173" cy="10.0581581" rx="1.74395553" ry="2.06791482"></ellipse>
</g>
<path d="M89.98668,79.215318 C86.7049076,87.1579286 91.8167094,103.039865 100.720696,91.3255134 C100.084188,86.2874005 100.958085,81.4097058 101.118719,76.3699504 L89.98668,79.215318 Z" id="path3206" fill="#FFFFFF" fill-rule="nonzero"></path>
<g id="g3208" transform="translate(80.043228, 62.962723)" fill="#F6D2A2" fill-rule="nonzero">
<path d="M9.4215265,0.957033389 C3.30534671,1.47770773 -1.69461381,8.74032095 1.48573388,14.4858063 C5.69732344,22.0960832 15.0974685,13.8123789 20.9521372,14.5887367 C27.6905709,14.7267071 33.2113606,21.7040718 38.6235986,15.8501811 C44.6427397,9.33928807 36.032062,2.99812062 29.3024003,0.163155578 L9.4215265,0.957033389 Z" id="path3210"></path>
</g>
</g>
<g id="g3212" transform="translate(36.933174, 116.348662)" fill-rule="nonzero">
<path d="M2.87059137,0.191078177 C2.87059137,0.191078177 4.8069796,21.1390498 4.6743052,30.1689993 C4.54163081,39.1984013 13.2049396,33.4972636 13.6544975,47.6978214 C14.1040553,61.8983792 8.40673188,59.2052171 5.40511084,66.1557542 C2.4034898,73.1057438 0.363483973,105.655282 0.363483973,105.655282 C0.363483973,105.655282 3.44350352,111.736933 13.0640416,117.213048 C22.6845797,122.689162 38.1543041,125.765028 51.5494834,125.158943 C64.9446627,124.552859 74.5125697,121.687233 76.9050946,120.102215 C79.2970714,118.517745 83.2674348,113.504817 84.760844,109.627956 C86.2542533,105.751094 89.3742944,73.8202339 89.1566426,55.5484517 C88.9389909,37.2766695 85.9159884,20.4174423 85.9159884,20.4174423 L2.87059137,0.191078177 Z" id="path3214" fill="#EF9325"></path>
<path d="M87.7224434,89.1557632 C48.2753864,93.5532987 2.50436619,81.3965657 2.50436619,81.3965657 C2.50436619,81.3965657 3.17651001,69.0662745 6.13701296,64.7267742 C47.1635531,77.4206066 88.5535772,73.3959199 88.5535772,73.3959199 C89.1676075,79.1523553 88.7350451,83.7283765 87.7224434,89.1557632 Z" id="path3216" fill="#E5E5E5"></path>
<path d="M82.7570221,113.457732 C43.3099651,117.855267 0.363483973,105.210163 0.363483973,105.210163 C0.363483973,105.210163 0.22587541,93.3671481 1.79274901,88.1664272 C42.8192892,100.86026 86.6402151,97.6967934 86.6402151,97.6967934 C86.2712488,103.945433 85.3913215,110.095522 82.7570221,113.457732 Z" id="path3218" fill="#E5E5E5"></path>
</g>
<g id="g3220" transform="translate(131.230675, 116.348662)" fill-rule="nonzero">
<path d="M82.3217185,0.191078177 C82.3217185,0.191078177 82.0541768,21.1390498 82.1884959,30.1689993 C82.322815,39.1984013 76.9374409,33.4972636 76.4818524,47.6978214 C76.0262638,61.8983792 81.7954069,59.2052171 84.8348566,66.1557542 C87.8743063,73.1057438 89.9400794,105.655282 89.9400794,105.655282 C89.9400794,105.655282 86.8211348,111.736933 77.0799836,117.213048 C67.3388324,122.689162 51.6744824,125.765028 38.110993,125.158943 C24.5475036,124.552859 14.8595318,121.687233 12.4368536,120.102215 C10.0141753,118.517197 5.99447022,113.504817 4.48242075,109.627956 C2.97037129,105.751094 -0.189143244,73.8202339 0.0312497533,55.5484517 C0.25164275,37.2766695 3.31247385,20.4174423 3.31247385,20.4174423 L82.3217185,0.191078177 Z" id="path3222" fill="#EF9325"></path>
<path d="M1.48354092,89.1557632 C41.426208,93.5532987 87.7723334,81.3965657 87.7723334,81.3965657 C87.7723334,81.3965657 87.0914177,69.0662745 84.0941826,64.7267742 C42.5517474,77.4206066 0.641990546,73.3959199 0.641990546,73.3959199 C0.0202849276,79.1523553 0.458329715,83.7283765 1.48354092,89.1557632 Z" id="path3224" fill="#E5E5E5"></path>
<path d="M6.51091351,113.457732 C46.4535806,117.855267 89.9395312,105.210163 89.9395312,105.210163 C89.9395312,105.210163 90.0787845,93.3671481 88.4921742,88.1664272 C46.949739,100.86026 2.57892701,97.6967934 2.57892701,97.6967934 C2.95282757,103.945433 3.84371966,110.095522 6.51091351,113.457732 Z" id="path3226" fill="#E5E5E5"></path>
</g>
<g id="g3228" transform="translate(207.984455, 94.996087)" fill-rule="nonzero">
<path d="M21.5025715,59.9651498 C20.2613532,62.3040781 17.804684,63.4330271 16.0152244,62.4858487 L14.5815735,61.7275586 C12.7921139,60.7809277 12.346942,58.1167833 13.5881603,55.777855 L41.5402422,3.08955344 C42.7809123,0.750625158 45.2381297,-0.37832384 47.0275893,0.568854514 L48.4612402,1.3271447 C50.2506998,2.27377555 50.6958717,4.93791998 49.4546534,7.27684827 L21.5025715,59.9651498 Z" id="path3230" fill="#D2E261"></path>
<path d="M8.30201779,84.8474704 C7.57888754,86.2102027 5.54217116,86.5474639 3.7527116,85.6002856 L2.31906064,84.8419954 C0.529601082,83.8953646 -0.335523667,82.0229079 0.387606589,80.6601756 L13.5015381,55.9410106 C14.2246684,54.5782782 16.2613848,54.2410171 18.0508443,55.1881954 L19.4844953,55.9464856 C21.2739549,56.8931164 22.1390796,58.7655731 21.4159494,60.1283054 L8.30201779,84.8474704 Z" id="path3232" fill="#000000"></path>
<path d="M19.9620135,55.0458449 L18.5283625,54.2875547 C16.7389029,53.3409239 14.907777,53.2894587 14.4390307,54.1725793 L13.6007698,55.7526699 C14.0695161,54.8690018 15.9000938,54.920467 17.6901016,55.8676453 L19.1237525,56.6259355 C20.9132121,57.5725663 21.9844756,59.0568441 21.5157293,59.9399647 L22.3539902,58.3598741 C22.8227365,57.4767535 21.751473,55.9930233 19.9620135,55.0458449 Z" id="path3234" fill="#9B9B9B"></path>
</g>
<ellipse id="ellipse3236" fill="#F6D2A2" fill-rule="nonzero" transform="translate(216.136985, 164.336270) rotate(20.413879) translate(-216.136985, -164.336270) " cx="216.136985" cy="164.33627" rx="6.15347641" ry="4.75397164"></ellipse>
<g id="g3238" transform="translate(0.749249, 94.996087)" fill-rule="nonzero">
<path d="M26.1944204,60.8154203 C27.3265387,63.2090988 29.728932,64.4491907 31.5600579,63.5852326 L33.0266033,62.8931902 C34.8577292,62.0292321 35.4246107,59.3886303 34.2924924,56.9949518 L8.7894043,3.08188841 C7.65728604,0.688209937 5.25489273,-0.551881954 3.42376683,0.312076105 L1.95722139,1.00411856 C0.126095496,1.86807661 -0.440785994,4.50867846 0.691332262,6.90235694 L26.1944204,60.8154203 Z" id="path3240" fill="#D2E261"></path>
<path d="M38.2387332,86.2764505 C38.8982675,87.6709379 40.9174401,88.1007269 42.7491143,87.2373164 L44.2156597,86.5452739 C46.0467856,85.6813159 46.9968878,83.8504694 46.3368052,82.455982 L34.3714392,57.1619399 C33.7119049,55.7674525 31.6927323,55.3376634 29.8610581,56.201074 L28.3945127,56.8931164 C26.5633868,57.7570745 25.6132846,59.587921 26.2733671,60.9824084 L38.2387332,86.2764505 Z" id="path3242" fill="#000000"></path>
<path d="M27.9592091,55.9722182 L29.4257545,55.2801757 C31.2568804,54.4162177 33.0880063,54.4485203 33.5156345,55.352446 L34.2804311,56.9697667 C33.8528029,56.065841 32.021677,56.0335384 30.1905511,56.896949 L28.7240057,57.5889914 C26.8928798,58.4529495 25.7547309,59.8863095 26.1823591,60.7902352 L25.4175625,59.1729145 C24.9904825,58.2695363 26.1280832,56.8361762 27.9592091,55.9722182 Z" id="path3244" fill="#9B9B9B"></path>
</g>
<ellipse id="ellipse3246" fill="#F6D2A2" fill-rule="nonzero" transform="translate(41.089114, 165.024431) rotate(159.586121) translate(-41.089114, -165.024431) " cx="41.0891136" cy="165.024431" rx="6.15347641" ry="4.75397164"></ellipse>
<g id="g3248" transform="translate(26.516589, 0.825753)" fill-rule="nonzero">
<g id="g3250" transform="translate(2.192965, 25.732591)">
<path d="M24.1949844,62.2684905 C22.9789853,64.4765658 20.1105869,65.232666 17.7876885,63.9569865 L15.1161087,62.4902288 C12.7932104,61.2145492 11.8962877,58.3905342 13.1117386,56.1824589 L40.9152472,5.68635513 C42.1312463,3.47827982 44.9996448,2.72217964 47.3225431,3.99785916 L49.9941229,5.46461685 C52.3170212,6.74029637 52.9162489,9.12685732 51.998493,11.7723867 C42.2589866,39.836241 24.1949844,62.2684905 24.1949844,62.2684905 Z" id="path3252" fill="#960000"></path>
<path d="M31.5496413,33.4255409 C40.0046184,18.069754 45.5599473,4.86291222 44.2364929,3.36494691 L44.2677426,3.30855421 C44.2315587,3.28884414 44.1931818,3.2757041 44.1569979,3.25599403 C44.152612,3.25325652 44.1515155,3.24449649 44.1465813,3.24175898 L44.1427436,3.24942401 C31.1499734,-3.78104839 13.6698482,3.18153376 5.03449973,18.8652743 C-3.60084877,34.5490148 -0.124450772,53.0189927 12.7800526,60.2093357 L12.7762149,60.2170007 C12.7811491,60.2197382 12.7888245,60.2159057 12.7937586,60.2180957 C12.8299426,60.2383533 12.8611923,60.2635384 12.8973763,60.2832484 L12.928626,60.2268557 C14.9033911,60.5405744 23.0946642,48.7813277 31.5496413,33.4255409 Z" id="path3254" fill="#595959"></path>
</g>
<g id="g3256" transform="translate(146.380423, 25.732591)">
<path d="M26.6955129,62.3741584 C27.8468196,64.6161788 30.6927402,65.4549518 33.0512742,64.246615 L35.763972,62.8570551 C38.1225061,61.6492658 39.1011168,58.8520784 37.9498101,56.6100579 L11.6172328,5.33376389 C10.4659261,3.09174345 7.62000563,2.25297048 5.26147162,3.46130726 L2.54877374,4.85086718 C0.190239726,6.05865645 -0.478066401,8.42769734 0.362935731,11.0978643 C9.28775563,39.4305421 26.6955129,62.3741584 26.6955129,62.3741584 Z" id="path3258" fill="#960000"></path>
<path d="M20.1774723,33.3313705 C12.1698601,17.7385153 6.99830002,4.3767305 8.3645173,2.91763783 L8.33491227,2.86015013 C8.37164444,2.84153506 8.41056957,2.82949002 8.44730173,2.81087495 C8.45223591,2.80813744 8.45333239,2.79937741 8.45826656,2.7971874 L8.46210425,2.80485243 C21.6522413,-3.84839113 38.9240348,3.6151553 47.1026983,19.5414392 C55.2813618,35.4677231 51.2726215,53.8298431 38.1658171,60.6451472 L38.1696548,60.6528123 C38.1647206,60.6550023 38.1570453,60.6511698 38.1521111,60.6533598 C38.1153789,60.6725223 38.0835809,60.6971599 38.0468488,60.715775 L38.0172437,60.6582873 C36.0337068,60.9145182 28.1850845,48.9242257 20.1774723,33.3313705 Z" id="path3260" fill="#595959"></path>
</g>
<path d="M192.630058,52.4884633 C192.630058,52.4884633 182.900968,28.1541922 171.618163,15.2884441 C160.335357,2.42324358 49.9431364,-1.96443696 31.8149901,15.2884441 C13.6868437,32.5413252 6.85191959,52.4884633 6.85191959,52.4884633 L6.85191959,47.0988543 C6.85191959,47.0988543 15.2372701,23.4095404 31.8149901,9.89883505 C48.3927101,-3.61187029 157.33264,-2.90066527 171.387353,9.89883505 C185.441518,22.6983354 192.630058,47.0988543 192.630058,47.0988543 L192.630058,52.4884633 Z" id="path3262" fill="#353535"></path>
<ellipse id="ellipse3264" fill="#960000" transform="translate(192.490339, 46.095741) rotate(152.850710) translate(-192.490339, -46.095741) " cx="192.490339" cy="46.0957411" rx="3.8096405" ry="9.29363039"></ellipse>
<ellipse id="ellipse3266" fill="#960000" transform="translate(8.209378, 45.461228) rotate(28.802510) translate(-8.209378, -45.461228) " cx="8.20937794" cy="45.4612284" rx="3.80979137" ry="9.29399843"></ellipse>
</g>
<g id="g3268" transform="translate(59.959308, 47.910920)" fill="#FFFFFF" fill-rule="nonzero">
<path d="M117.592273,0.269370954 L97.1949564,0.269370954 L81.0251279,0.269370954 L56.2248851,0.269370954 L42.214579,0.269370954 L14.6270775,0.269370954 C-3.85961865,0.269370954 -3.79602266,49.3288297 13.5607482,52.1566772 L49.4519122,49.851694 C53.791242,49.851694 62.4556473,33.0680221 64.0811827,31.7485424 C65.7067181,30.4290627 71.059746,30.2680972 73.168282,31.7485424 C75.276818,33.2289877 80.0722845,48.1604605 84.4110661,48.1604605 L120.30223,52.1561297 C137.0669,44.4516824 132.840508,0.269370954 117.592273,0.269370954 Z" id="path3270" opacity="0.6"></path>
<path d="M41.9651292,10.4129394 C30.6247582,10.1599935 12.880929,10.2629238 3.18857132,10.3417641 C1.56358415,15.6629355 -0.248353303,22.3999468 0.650214165,28.9415 C9.45277625,28.9415 28.5885901,28.9842051 42.1970353,28.9842051 C50.3910496,28.9842051 57.2802496,30.5281606 63.6869972,32.200779 C63.8448907,31.9992983 63.9775651,31.8509253 64.0811827,31.7671575 C65.7067181,30.4476778 71.059746,30.2867122 73.168282,31.7671575 C73.7137821,32.1504088 74.4418465,33.4414184 75.2921688,35.1337469 C78.5635245,35.8225043 81.8398144,36.2982835 85.2443928,36.3409887 C94.5178942,36.4581541 116.251275,36.420924 130.236362,36.3815038 C132.017598,30.4372753 130.704012,24.0654477 130.304892,18.077419 C116.650395,18.077419 94.9504566,17.7697229 84.5470299,17.7697229 C69.8881544,17.7691754 57.0894616,10.7502006 41.9651292,10.4129394 Z" id="path3272" opacity="0.5"></path>
</g>
<path d="M117.40458,89.6124959 C116.951733,79.0303842 137.154428,77.707617 139.540375,86.5651023 C141.920294,95.4023323 118.399097,97.4549142 117.40458,89.6124959 C116.610728,83.3507162 117.40458,89.6124959 117.40458,89.6124959 Z" id="path3274" fill="#000000" fill-rule="nonzero"></path>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,16 +0,0 @@
import { boot } from 'quasar/wrappers'
import axios from 'axios'
import { APP } from '../_helpers/APP'
// Set config defaults when creating the instance
const api = axios.create({
baseURL: window.APIURL || APP.config.apiUrl
})
export default boot(({ app }) => {
app.config.globalProperties.$axios = axios
app.config.globalProperties.$api = api
APP.api = api
})
export { api }

View file

@ -0,0 +1,28 @@
import { AriaTr, VariantProps, styled } from '@traefiklabs/faency'
import { ComponentProps, forwardRef, ReactNode } from 'react'
import { useHref } from 'react-router-dom'
const UnstyledLink = styled('a', {
color: 'inherit',
textDecoration: 'inherit',
fontWeight: 'inherit',
'&:hover': {
cursor: 'pointer',
},
})
type ClickableRowProps = ComponentProps<typeof AriaTr> &
VariantProps<typeof AriaTr> & {
children: ReactNode
to: string
}
export default forwardRef<HTMLTableRowElement | null, ClickableRowProps>(({ children, css, to, ...props }, ref) => {
const href = useHref(to)
return (
<AriaTr asChild interactive ref={ref} css={css} {...props}>
<UnstyledLink href={href}>{children}</UnstyledLink>
</AriaTr>
)
})

View file

@ -0,0 +1,29 @@
import { Button } from '@traefiklabs/faency'
import { useCallback, useEffect, useState } from 'react'
export const ScrollTopButton = () => {
const [showOnScroll, setShowOnScroll] = useState<boolean>(false)
const handleScroll = useCallback(() => {
const position = window?.scrollY || 0
setShowOnScroll(position >= 160)
}, [setShowOnScroll])
useEffect(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
return () => {
window.removeEventListener('scroll', handleScroll)
}
}, [handleScroll])
if (!showOnScroll) {
return null
}
return (
<Button variant="primary" onClick={(): void => window.scrollTo({ top: 0, behavior: 'smooth' })}>
Scroll to top
</Button>
)
}

View file

@ -0,0 +1,18 @@
import { Flex } from '@traefiklabs/faency'
import { motion } from 'framer-motion'
import { FiLoader } from 'react-icons/fi'
export const SpinnerLoader = () => (
<motion.div
animate={{
rotate: 360,
}}
transition={{ ease: 'linear', duration: 1, repeat: Infinity }}
style={{ width: 24, height: 24 }}
data-testid="loading"
>
<Flex css={{ color: '$primary' }}>
<FiLoader size={24} />
</Flex>
</motion.div>
)

View file

@ -0,0 +1,116 @@
import { Box, Button, Flex, TextField } from '@traefiklabs/faency'
// eslint-disable-next-line import/no-unresolved
import { InputHandle } from '@traefiklabs/faency/dist/components/Input'
import { isUndefined, omitBy } from 'lodash'
import { useCallback, useRef, useState } from 'react'
import { FiSearch, FiXCircle } from 'react-icons/fi'
import { URLSearchParamsInit, useSearchParams } from 'react-router-dom'
import { useDebounceCallback } from 'usehooks-ts'
import IconButton from 'components/buttons/IconButton'
type State = {
search?: string
status?: string
sortBy?: string
direction?: string
}
export const searchParamsToState = (searchParams: URLSearchParams): State => {
if (searchParams.size <= 0) return {}
return omitBy(
{
direction: searchParams.get('direction') || undefined,
search: searchParams.get('search') || undefined,
sortBy: searchParams.get('sortBy') || undefined,
status: searchParams.get('status') || undefined,
},
isUndefined,
)
}
type Status = {
id: string
value?: string
name: string
}
const statuses: Status[] = [
{ id: 'all', value: undefined, name: 'All status' },
{ id: 'enabled', value: 'enabled', name: 'Success' },
{ id: 'warning', value: 'warning', name: 'Warnings' },
{ id: 'disabled', value: 'disabled', name: 'Errors' },
]
export const TableFilter = ({ hideStatusFilter }: { hideStatusFilter?: boolean }) => {
const [searchParams, setSearchParams] = useSearchParams()
const [state, setState] = useState(searchParamsToState(searchParams))
const searchInputRef = useRef<InputHandle>(null)
const onSearch = useDebounceCallback((search?: string) => {
const newState = omitBy({ ...state, search: search || undefined }, isUndefined)
setState(newState)
setSearchParams(newState as URLSearchParamsInit)
}, 500)
const onStatusClick = useCallback(
(status?: string) => {
const newState = omitBy({ ...state, status: status || undefined }, isUndefined)
setState(newState)
setSearchParams(newState as URLSearchParamsInit)
},
[setSearchParams, state],
)
return (
<Flex css={{ alignItems: 'center', justifyContent: 'space-between', mb: '$5' }}>
<Flex>
{!hideStatusFilter &&
statuses.map(({ id, value, name }) => (
<Button
key={id}
css={{ marginRight: '$3', boxShadow: 'none' }}
ghost={state.status !== value}
variant={state.status !== value ? 'secondary' : 'primary'}
onClick={() => onStatusClick(value)}
>
{name}
</Button>
))}
</Flex>
<Box css={{ maxWidth: 200, position: 'relative' }}>
<TextField
ref={searchInputRef}
data-testid="table-search-input"
defaultValue={state.search || ''}
onChange={(e) => {
onSearch(e.target?.value)
}}
placeholder="Search"
css={{ input: { paddingRight: '$6' } }}
endAdornment={
state.search ? (
<IconButton
type="button"
css={{ height: '20px', p: 0, color: 'currentColor', '&:before, &:after': { borderRadius: '10px' } }}
ghost
icon={<FiXCircle size={20} />}
onClick={() => {
onSearch('')
searchInputRef.current?.clear()
}}
title="Clear search"
/>
) : (
<FiSearch color="hsl(0, 0%, 56%)" size={20} />
)
}
/>
</Box>
</Flex>
)
}
export default TableFilter

View file

@ -0,0 +1,29 @@
import { AccessibleIcon, Button } from '@traefiklabs/faency'
import { FiMoon, FiSun } from 'react-icons/fi'
import { AutoThemeIcon } from 'components/icons/AutoThemeIcon'
import { useTheme } from 'hooks/use-theme'
export default function ThemeSwitcher() {
const { selectedTheme, setTheme } = useTheme()
return (
<Button
ghost
css={{ px: '$2', color: '$buttonSecondaryText' }}
onClick={setTheme}
type="button"
data-testid="theme-switcher"
>
<AccessibleIcon label="toggle theme">
{selectedTheme === 'dark' ? (
<FiMoon size={20} />
) : selectedTheme === 'light' ? (
<FiSun size={20} />
) : (
<AutoThemeIcon />
)}
</AccessibleIcon>
</Button>
)
}

View file

@ -0,0 +1,102 @@
import { Box, Button, Flex, styled, Text } from '@traefiklabs/faency'
import { AnimatePresence, motion } from 'framer-motion'
import { ReactNode, useEffect } from 'react'
import { FiX } from 'react-icons/fi'
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
const CloseButton = styled(Button, {
position: 'absolute',
top: 0,
right: 0,
padding: 0,
})
const ToastContainer = styled(Flex, {
marginBottom: '$3',
width: '100%',
position: 'relative',
padding: '$5 $6',
borderRadius: '$2',
})
const AnimatedToastContainer = motion.create(ToastContainer)
const toastVariants = {
create: {
opacity: 0,
y: 100,
},
visible: {
opacity: 1,
y: 0,
},
hidden: {
opacity: 0,
x: '100%',
scale: 0,
},
}
export type ToastState = {
severity: StatusType
message?: string
isVisible?: boolean
key?: string
}
type ToastProps = ToastState & {
dismiss: () => void
icon?: ReactNode
timeout?: number
}
export const Toast = ({ message, dismiss, severity = 'error', icon, isVisible = true, timeout = 0 }: ToastProps) => {
useEffect(() => {
if (timeout) {
setTimeout(() => dismiss(), timeout)
}
}, [timeout, dismiss])
const propsBySeverity = {
info: {
color: colorByStatus.info,
icon: iconByStatus.info,
},
success: {
color: colorByStatus.success,
icon: iconByStatus.success,
},
warning: {
color: colorByStatus.warning,
icon: iconByStatus.warning,
},
error: {
color: colorByStatus.error,
icon: iconByStatus.error,
},
}
return (
<AnimatePresence>
{isVisible && (
<AnimatedToastContainer
css={{ backgroundColor: propsBySeverity[severity].color }}
gap={2}
initial="create"
animate="visible"
exit="hidden"
variants={toastVariants}
>
<Box css={{ width: '$4', height: '$4' }}>{icon ? icon : propsBySeverity[severity].icon}</Box>
<Text css={{ color: 'white', fontWeight: 600, lineHeight: '$4' }}>{message}</Text>
{!timeout && (
<CloseButton ghost onClick={dismiss} css={{ px: '$2' }}>
<FiX color="#fff" size={20} />
</CloseButton>
)}
</AnimatedToastContainer>
)}
</AnimatePresence>
)
}

View file

@ -0,0 +1,79 @@
import { waitFor } from '@testing-library/react'
import { useContext, useEffect } from 'react'
import { ToastPool } from './ToastPool'
import { ToastContext, ToastProvider } from 'contexts/toasts'
import { renderWithProviders } from 'utils/test'
describe('<ToastPool />', () => {
it('should render the toast pool', () => {
renderWithProviders(<ToastPool />)
})
it('should render toasts from context', async () => {
const Component = () => {
const { addToast } = useContext(ToastContext)
useEffect(() => {
addToast({
message: 'Test 1',
severity: 'success',
})
}, [addToast])
return <ToastPool />
}
const { getByTestId } = renderWithProviders(
<ToastProvider>
<Component />
</ToastProvider>,
)
await waitFor(() => getByTestId('toast-pool'))
const toastPool = getByTestId('toast-pool')
expect(toastPool.childNodes.length).toBe(1)
expect(toastPool.innerHTML).toContain('Test 1')
})
it('should render all valid severities of toasts', async () => {
const Component = () => {
const { addToast } = useContext(ToastContext)
useEffect(() => {
addToast({
message: 'Test 2',
severity: 'error',
})
addToast({
message: 'Test 3',
severity: 'warning',
})
addToast({
message: 'Test 4',
severity: 'info',
})
}, [addToast])
return <ToastPool />
}
const { getByTestId } = renderWithProviders(
<ToastProvider>
<Component />
</ToastProvider>,
)
await waitFor(() => getByTestId('toast-pool'))
const toastPool = getByTestId('toast-pool')
expect(toastPool.childNodes.length).toBe(3)
expect(toastPool.innerHTML).toContain('Test 2')
expect(toastPool.innerHTML).toContain('Test 3')
expect(toastPool.innerHTML).toContain('Test 4')
})
})

View file

@ -0,0 +1,37 @@
import { Flex } from '@traefiklabs/faency'
import { useContext } from 'react'
import { Toast } from './Toast'
import { ToastContext } from 'contexts/toasts'
import { getPositionValues, PositionXProps, PositionYProps } from 'utils/position'
export type ToastPoolProps = {
positionX?: PositionXProps
positionY?: PositionYProps
toastTimeout?: number
}
export const ToastPool = ({ positionX = 'right', positionY = 'bottom', toastTimeout = 5000 }: ToastPoolProps) => {
const { toasts, hideToast } = useContext(ToastContext)
return (
<Flex
{...getPositionValues(positionX, positionY)}
css={{
position: 'fixed',
bottom: 0,
flexDirection: 'column',
maxWidth: '380px',
zIndex: 2,
px: '$3',
margin: positionX === 'center' ? 'auto' : 0,
}}
data-testid="toast-pool"
>
{toasts?.map((toast, key) => (
<Toast key={`toast-${key}`} {...toast} dismiss={(): void => hideToast(toast)} timeout={toastTimeout} />
))}
</Flex>
)
}

View file

@ -0,0 +1,47 @@
import { Button, Flex, Text, Tooltip as FaencyTooltip } from '@traefiklabs/faency'
import { MouseEvent, ReactNode, useMemo, useState } from 'react'
import { FiCheck, FiCopy } from 'react-icons/fi'
type TooltipProps = {
action?: 'copy'
children: ReactNode
label: string
}
export default function Tooltip({ action, children, label }: TooltipProps) {
const [showConfirmation, setShowConfirmation] = useState(false)
const actionComponent = useMemo(() => {
if (action === 'copy') {
return (
<Button
css={{ padding: '0 $2 !important' }}
onClick={async (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
await navigator.clipboard.writeText(label)
setShowConfirmation(true)
setTimeout(() => setShowConfirmation(false), 1500)
}}
>
{showConfirmation ? <FiCheck size={16} /> : <FiCopy size={16} />}
</Button>
)
}
return null
}, [action, label, showConfirmation])
return (
<FaencyTooltip
content={
<Flex align="center" gap={2} css={{ px: '$1' }}>
<Text css={{ maxWidth: '240px !important', color: '$contrast', wordBreak: 'break-word' }}>{label}</Text>{' '}
{actionComponent}
</Flex>
}
>
{children}
</FaencyTooltip>
)
}

View file

@ -0,0 +1,28 @@
import { CSS, Text } from '@traefiklabs/faency'
import { useMemo } from 'react'
import Tooltip from 'components/Tooltip'
type TooltipTextProps = {
isTruncated?: boolean
text?: string
css?: CSS
}
export default function TooltipText({ isTruncated = false, text, css }: TooltipTextProps) {
const appliedCss = useMemo(
() =>
isTruncated
? { whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '100%', ...css }
: css,
[isTruncated, css],
)
if (typeof text === 'undefined') return <Text>-</Text>
return (
<Tooltip label={text} action="copy">
<Text css={appliedCss}>{text}</Text>
</Tooltip>
)
}

View file

@ -1,43 +0,0 @@
<template>
<q-avatar
:color="state"
text-color="white"
>
<q-icon
v-if="state === 'positive'"
name="eva-checkmark-circle-2"
/>
<q-icon
v-if="state === 'warning'"
name="eva-alert-circle"
/>
<q-icon
v-if="state === 'negative'"
name="eva-alert-triangle"
/>
</q-avatar>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'AvatarState',
props: {
state: { type: String, default: undefined, required: false }
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-avatar{
font-size: 32px;
border-radius: 4px;
.q-icon {
font-size: 22px;
margin: 0 0 0 1px;
}
}
</style>

View file

@ -1,71 +0,0 @@
<template>
<div class="block-right-text">
<q-avatar
:color="value ? 'positive' : 'negative'"
text-color="white"
>
<q-icon
v-if="value"
name="eva-toggle-right"
/>
<q-icon
v-if="!value"
name="eva-toggle-left"
/>
</q-avatar>
<div :class="['block-right-text-label', `block-right-text-label-${!!value}`]">
{{ value ? 'True' : 'False' }}
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'BooleanState',
props: {
value: {
type: Boolean,
default: true
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-avatar{
font-size: 32px;
border-radius: 4px;
.q-icon {
font-size: 22px;
margin: 0 0 0 1px;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
.q-avatar{
float: left;
}
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-true {
color: $positive;
}
&-false {
color: $negative;
}
}
}
</style>

View file

@ -1,34 +0,0 @@
<script>
import { Doughnut } from 'vue-chartjs'
import isEqual from 'lodash.isequal'
export default {
extends: Doughnut,
props: {
chartdata: {
type: Object,
default: null
},
options: {
type: Object,
default: null
}
},
watch: {
chartdata: function (newData, oldData) {
// TODO - bug, 'update()' not update the chart, replace for renderChart()
// console.log('new data from watcher...', newData, oldData, isEqual(newData.datasets[0].data, oldData.datasets[0].data))
if (!isEqual(newData.datasets[0].data, oldData.datasets[0].data)) {
// this.$data._chart.update()
this.renderChart(this.chartdata, this.options)
}
},
'$q.dark.isActive' (val) {
this.renderChart(this.chartdata, this.options)
}
},
mounted () {
this.renderChart(this.chartdata, this.options)
}
}
</script>

View file

@ -1,22 +0,0 @@
<template>
<div>
<q-chip
v-for="(chip, index) in list"
:key="index"
:dense="dense"
:class="classNames"
>
{{ chip }}
</q-chip>
</div>
</template>
<script>
export default {
props: {
dense: { type: Boolean, default: undefined },
classNames: Array[String],
list: Array[Object]
}
}
</script>

View file

@ -1,216 +0,0 @@
<template>
<div class="table-wrapper">
<q-infinite-scroll
ref="scroller"
:offset="250"
@load="handleLoadMore"
>
<q-markup-table>
<thead>
<tr class="table-header">
<th
v-for="column in columns"
:key="column.name"
:class="getColumn(column.name).sortable ? `text-${column.align} cursor-pointer`: `text-${column.align}`"
@click="getColumn(column.name).sortable ? onSortClick(column.name) : null"
>
{{ column.label }}
<i
v-if="currentSort === column.name"
class="material-icons"
>{{ currentSortDir === 'asc' ? 'arrow_drop_down' : 'arrow_drop_up' }}</i>
<i
v-else
style="opacity: 0"
class="material-icons"
>{{ currentSortDir === 'asc' ? 'arrow_drop_down' : 'arrow_drop_up' }}</i>
</th>
</tr>
</thead>
<tfoot v-if="!data || !data.length">
<tr>
<td colspan="100%">
<q-icon
name="warning"
style="font-size: 1.5rem"
/> No data available
</td>
</tr>
</tfoot>
<tbody>
<tr
v-for="row in data"
:key="row.name"
class="cursor-pointer"
@click="onRowClick(row)"
>
<template v-for="column in columns">
<td
v-if="getColumn(column.name).component"
:key="column.name"
:class="`text-${getColumn(column.name).align}`"
>
<component
:is="getColumn(column.name).component"
v-bind="getColumn(column.name).fieldToProps(row)"
>
<template v-if="getColumn(column.name).content && column.name !== 'priority'">
{{ getColumn(column.name).content(row) }}
</template>
<template v-if="getColumn(column.name).content && column.name === 'priority'">
<div>
{{ getColumn(column.name).content(row).short }}
</div>
<q-tooltip
anchor="top middle"
self="bottom middle"
:offset="[10, 10]"
>
<div class="priority-tooltip">
{{ getColumn(column.name).content(row).long }}
</div>
</q-tooltip>
</template>
</component>
</td>
<td
v-if="!getColumn(column.name).component"
:key="column.name"
:class="`text-${getColumn(column.name).align}`"
v-bind="getColumn(column.name).fieldToProps(row)"
>
<span>
{{ getColumn(column.name).content ? getColumn(column.name).content(row) : row[column.name] }}
</span>
</td>
</template>
</tr>
</tbody>
</q-markup-table>
<template
v-if="loading"
#loading
>
<div class="row justify-center q-my-md">
<q-spinner-dots
color="app-grey"
size="40px"
/>
</div>
</template>
</q-infinite-scroll>
<q-page-scroller
v-if="endReached"
position="bottom"
:scroll-offset="150"
class="back-to-top"
>
<q-btn
color="primary"
small
>
Back to top
</q-btn>
</q-page-scroller>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import { QMarkupTable, QInfiniteScroll, QSpinnerDots, QPageScroller } from 'quasar'
export default defineComponent({
name: 'MainTable',
components: {
QMarkupTable,
QInfiniteScroll,
QSpinnerDots,
QPageScroller
},
props: {
data: { type: Object, default: undefined, required: false },
columns: Array[Object],
loading: Boolean,
onLoadMore: { type: Function, default: undefined, required: false },
endReached: Boolean,
onRowClick: { type: Function, default: undefined, required: false }
},
emits: ['update:currentSort', 'update:currentSortDir'],
data () {
return {
currentSort: 'name',
currentSortDir: 'asc'
}
},
methods: {
getColumn (columnName) {
return this.columns.find(c => c.name === columnName) || {}
},
handleLoadMore (index, done) {
if (!this?.onLoadMore) {
done()
} else {
this.onLoadMore({ page: index })
.then(() => done())
.catch(() => done(true))
}
},
onSortClick (s) {
if (s === this.currentSort) {
this.currentSortDir = this.currentSortDir === 'asc' ? 'desc' : 'asc'
}
this.currentSort = s
this.$emit('update:currentSort', s)
this.$emit('update:currentSortDir', this.currentSortDir)
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.table-wrapper {
:deep(.q-table__container) {
border-radius: 8px;
.q-table {
.table-header {
th {
font-size: 14px;
font-weight: 700;
}
}
tbody {
tr:hover {
background: rgba( $accent, 0.04 );
}
}
}
.q-table__bottom {
> .q-table__control {
&:nth-last-child(2) {
display: none;
}
&:nth-last-child(1) {
.q-table__bottom-item {
display: none;
}
}
}
}
}
.back-to-top {
margin: 16px 0;
}
}
.servers-label {
font-size: 14px;
font-weight: 600;
}
.priority-tooltip{
font-size: larger;
}
</style>

File diff suppressed because one or more lines are too long

View file

@ -1,15 +0,0 @@
<template>
<q-page>
<slot />
</q-page>
</template>
<script>
export default {
name: 'PageDefault'
}
</script>
<style scoped lang="scss">
</style>

View file

@ -1,192 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-health-check', {'panel-health-check-dense':isDense}]"
>
<q-scroll-area
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section v-if="data.scheme || data.interval || data.unhealthyInterval">
<div class="row items-start no-wrap">
<div
v-if="data.scheme"
class="col"
>
<div class="text-subtitle2">
SCHEME
</div>
<q-chip
dense
class="app-chip app-chip-options"
>
{{ data.scheme }}
</q-chip>
</div>
<div
v-if="data.interval"
class="col"
>
<div class="text-subtitle2">
INTERVAL
</div>
<q-chip
dense
class="app-chip app-chip-interval"
>
{{ data.interval }}
</q-chip>
</div>
<div
v-if="data.unhealthyInterval"
class="col"
>
<div class="text-subtitle2">
UNHEALTHY INTERVAL
</div>
<q-chip
dense
class="app-chip app-chip-interval"
>
{{ data.unhealthyInterval }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.path || data.timeout">
<div class="row items-start no-wrap">
<div
v-if="data.path"
class="col"
>
<div class="text-subtitle2">
PATH
</div>
<q-chip
dense
class="app-chip app-chip-entry-points"
>
{{ data.path }}
</q-chip>
</div>
<div
v-if="data.timeout"
class="col"
>
<div class="text-subtitle2">
TIMEOUT
</div>
<q-chip
dense
class="app-chip app-chip-interval"
>
{{ data.timeout }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.port || data.hostname">
<div class="row items-start no-wrap">
<div
v-if="data.port"
class="col"
>
<div class="text-subtitle2">
PORT
</div>
<q-chip
dense
class="app-chip app-chip-name"
>
{{ data.port }}
</q-chip>
</div>
<div
v-if="data.hostname"
class="col"
>
<div class="text-subtitle2">
HOSTNAME
</div>
<q-chip
dense
class="app-chip app-chip-rule"
>
{{ data.hostname }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.headers">
<div class="row items-start">
<div class="col-12">
<div class="text-subtitle2">
HEADERS
</div>
</div>
<div
v-for="(header, index) in data.headers"
:key="index"
class="col-12"
>
<q-chip
dense
class="app-chip app-chip-wrap app-chip-service"
>
{{ index }}: {{ header }}
</q-chip>
</div>
</div>
</q-card-section>
</q-scroll-area>
</q-card>
</template>
<script>
export default {
name: 'PanelHealthCheck',
components: {
},
filters: {
},
props: {
data: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined }
},
computed: {
isDense () {
return this.dense !== undefined
}
}
}
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-health-check {
height: 600px;
&-dense {
height: 400px;
}
.q-card__section {
padding: 24px;
+ .q-card__section {
padding-top: 0;
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -1,178 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-services', {'panel-services-dense':isDense}]"
>
<q-scroll-area
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section>
<div class="row items-start no-wrap">
<div class="col-6">
<div class="text-subtitle2 text-table">
Name
</div>
</div>
<div class="col-3">
<div
class="text-subtitle2 text-table"
style="text-align: right"
>
Percent
</div>
</div>
<div class="col-3">
<div
class="text-subtitle2 text-table"
style="text-align: right"
>
Provider
</div>
</div>
</div>
</q-card-section>
<q-separator />
<div
v-for="(service, index) in data.mirroring.mirrors"
:key="index"
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col-6">
<q-chip
dense
class="app-chip app-chip-rule app-chip-overflow"
>
{{ service.name }}
<q-tooltip>{{ service.name }}</q-tooltip>
</q-chip>
</div>
<div class="col-3 text-right">
{{ service.percent }}
</div>
<div class="col-3 text-right">
<q-avatar class="provider-logo">
<q-icon :name="`img:${getProviderLogoPath(service)}`" />
</q-avatar>
</div>
</div>
</q-card-section>
<q-separator />
</div>
</q-scroll-area>
</q-card>
</template>
<script>
export default {
name: 'PanelMirroringServices',
props: {
data: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined }
},
computed: {
isDense () {
return this.dense !== undefined
}
},
methods: {
getProvider (service) {
const words = service.name.split('@')
if (words.length === 2) {
return words[1]
}
return this.data.provider
},
getProviderLogoPath (service) {
const provider = this.getProvider(service)
const name = provider.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
}
}
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-services {
height: 600px;
&-dense{
height: 400px;
}
.q-card__section {
padding: 12px 24px;
+ .q-card__section {
padding-top: 0;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-enabled {
color: $positive;
}
&-disabled {
color: $negative;
}
&-warning {
color: $warning;
}
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.text-table {
font-size: 14px;
font-weight: 700;
letter-spacing: normal;
text-transform: none;
}
.provider-logo {
width: 32px;
height: 32px;
img {
width: 100%;
height: 100%;
}
}
}
</style>

View file

@ -1,274 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-router-details']"
>
<q-scroll-area
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section>
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
STATUS
</div>
<div class="block-right-text">
<avatar-state :state="status(data.status)" />
<div :class="['block-right-text-label', `block-right-text-label-${data.status}`]">
{{ statusLabel(data.status) }}
</div>
</div>
</div>
<div class="col">
<div class="text-subtitle2">
PROVIDER
</div>
<div class="block-right-text">
<q-avatar class="provider-logo">
<q-icon :name="`img:${getProviderLogoPath}`" />
</q-avatar>
<div class="block-right-text-label">
{{ data.provider }}
</div>
</div>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.rule">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
RULE
</div>
<q-chip
dense
class="app-chip app-chip-wrap app-chip-rule"
>
{{ data.rule }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.name">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
NAME
</div>
<q-chip
dense
class="app-chip app-chip-wrap app-chip-name"
>
{{ data.name }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.using">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
ENTRYPOINTS
</div>
<q-chip
v-for="(entryPoint, index) in data.using"
:key="index"
dense
class="app-chip app-chip-entry-points"
>
{{ entryPoint }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.service">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
SERVICE
</div>
<q-chip
dense
clickable
class="app-chip app-chip-wrap app-chip-service app-chip-overflow"
@click="$router.push({ path: `/${protocol}/services/${getServiceId()}`})"
>
{{ data.service }}
<q-tooltip>{{ data.service }}</q-tooltip>
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.priority">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
PRIORITY
</div>
<q-chip
dense
class="app-chip app-chip-entry-points"
>
{{ data.priority }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.error">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
ERRORS
</div>
<q-chip
v-for="(errorMsg, index) in data.error"
:key="index"
class="app-chip app-chip-error"
>
{{ errorMsg }}
</q-chip>
</div>
</div>
</q-card-section>
</q-scroll-area>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
import AvatarState from './AvatarState.vue'
export default defineComponent({
name: 'PanelRouterDetails',
components: {
AvatarState
},
props: {
data: { type: Object, default: undefined, required: false },
protocol: { type: String, default: undefined, required: false }
},
computed: {
getProviderLogoPath () {
const name = this.data.provider.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
},
methods: {
getServiceId () {
const words = this.data.service.split('@')
if (words.length === 2) {
return this.data.service
}
return `${this.data.service}@${this.data.provider}`
},
status (value) {
if (value === 'enabled') {
return 'positive'
}
if (value === 'disabled') {
return 'negative'
}
return value
},
statusLabel (value) {
if (value === 'enabled') {
return 'success'
}
if (value === 'disabled') {
return 'error'
}
return value
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-router-details {
height: 600px;
.q-card__section {
padding: 24px;
+ .q-card__section {
padding-top: 0;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
.q-avatar{
float: left;
}
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-enabled {
color: $positive;
}
&-disabled {
color: $negative;
}
&-warning {
color: $warning;
}
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.app-chip {
&-error {
display: flex;
height: 100%;
flex-wrap: wrap;
border-width: 0;
margin-bottom: 8px;
:deep(.q-chip__content) {
white-space: normal;
}
}
}
.provider-logo {
width: 32px;
height: 32px;
img {
width: 100%;
height: 100%;
}
}
}
</style>

View file

@ -1,200 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-servers', {'panel-servers-dense':isDense}]"
>
<q-scroll-area
v-if="data.loadBalancer.servers"
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section>
<div class="row items-start no-wrap">
<div
v-if="showStatus"
class="col-3"
>
<div class="text-subtitle2 text-table">
Status
</div>
</div>
<div class="col-9">
<div class="text-subtitle2 text-table">
URL
</div>
</div>
</div>
</q-card-section>
<q-separator />
<div
v-for="(server, index) in data.loadBalancer.servers"
:key="index"
>
<q-card-section>
<div class="row items-center no-wrap">
<div
v-if="showStatus"
class="col-3"
>
<div class="block-right-text">
<avatar-state
v-if="data.serverStatus"
:state="status(data.serverStatus[server.url || server.address])"
/>
<avatar-state
v-if="!data.serverStatus"
:state="status('DOWN')"
/>
</div>
</div>
<div class="col-9">
<q-chip
dense
class="app-chip app-chip-rule"
>
{{ server.url || server.address }}
</q-chip>
</div>
</div>
</q-card-section>
<q-separator />
</div>
</q-scroll-area>
<q-card-section
v-else
style="height: 100%"
>
<div
class="row items-center"
style="height: 100%"
>
<div class="col-12">
<div class="block-empty" />
<div class="q-pb-lg block-empty-logo">
<img
v-if="$q.dark.isActive"
alt="empty"
src="~assets/middlewares-empty-dark.svg"
>
<img
v-else
alt="empty"
src="~assets/middlewares-empty.svg"
>
</div>
<div class="block-empty-label">
There is no<br>Server available
</div>
</div>
</div>
</q-card-section>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
import AvatarState from './AvatarState.vue'
export default defineComponent({
name: 'PanelServers',
components: {
AvatarState
},
props: {
data: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined },
hasStatus: { type: Boolean, default: undefined }
},
computed: {
isDense () {
return this.dense !== undefined
},
showStatus () {
return this.hasStatus !== undefined
}
},
methods: {
status (value) {
if (value === 'UP') {
return 'positive'
}
return 'negative'
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-servers {
height: 600px;
&-dense{
height: 400px;
}
.q-card__section {
padding: 12px 24px;
+ .q-card__section {
padding-top: 0;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
.q-avatar{
float: left;
}
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-enabled {
color: $positive;
}
&-disabled {
color: $negative;
}
&-warning {
color: $warning;
}
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.text-table {
font-size: 14px;
font-weight: 700;
letter-spacing: normal;
text-transform: none;
}
.block-empty {
&-logo {
text-align: center;
}
&-label {
font-size: 20px;
font-weight: 700;
color: #b8b8b8;
text-align: center;
line-height: 1.2;
}
}
}
</style>

View file

@ -1,279 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-service-details', {'panel-service-details-dense':isDense}]"
>
<q-scroll-area
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section>
<div class="row items-start no-wrap">
<div
v-if="data.type"
class="col"
>
<div class="text-subtitle2">
TYPE
</div>
<q-chip
dense
class="app-chip app-chip-entry-points"
>
{{ data.type }}
</q-chip>
</div>
<div class="col">
<div class="text-subtitle2">
PROVIDER
</div>
<div class="block-right-text">
<q-avatar class="provider-logo">
<q-icon :name="`img:${getProviderLogoPath}`" />
</q-avatar>
<div class="block-right-text-label">
{{ data.provider }}
</div>
</div>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
STATUS
</div>
<div class="block-right-text">
<avatar-state :state="status(data.status)" />
<div :class="['block-right-text-label', `block-right-text-label-${data.status}`]">
{{ statusLabel(data.status) }}
</div>
</div>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.mirroring">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
Main Service
</div>
<q-chip
dense
class="app-chip app-chip-name app-chip-overflow"
>
{{ data.mirroring.service }}
<q-tooltip>{{ data.mirroring.service }}</q-tooltip>
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.loadBalancer && $route.meta.protocol !== 'tcp'">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
Pass Host Header
</div>
<boolean-state :value="data.loadBalancer.passHostHeader" />
</div>
</div>
</q-card-section>
<q-card-section v-if="data.loadBalancer && data.loadBalancer.proxyProtocol">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
Proxy Protocol
</div>
<q-chip
dense
class="app-chip app-chip-name"
>
Version {{ data.loadBalancer.proxyProtocol.version }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.failover && data.failover.service">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
Main Service
</div>
<q-chip
dense
class="app-chip app-chip-name app-chip-overflow"
>
{{ data.failover.service }}
<q-tooltip>{{ data.failover.service }}</q-tooltip>
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.failover && data.failover.fallback">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
Fallback Service
</div>
<q-chip
dense
class="app-chip app-chip-name app-chip-overflow"
>
{{ data.failover.fallback }}
<q-tooltip>{{ data.failover.fallback }}</q-tooltip>
</q-chip>
</div>
</div>
</q-card-section>
<q-separator v-if="sticky" />
<StickyServiceDetails
v-if="sticky"
:sticky="sticky"
:dense="dense"
/>
</q-scroll-area>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
import AvatarState from './AvatarState.vue'
import BooleanState from './BooleanState.vue'
import StickyServiceDetails from './StickyServiceDetails.vue'
export default defineComponent({
name: 'PanelServiceDetails',
components: {
BooleanState,
AvatarState,
StickyServiceDetails
},
props: {
data: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined }
},
computed: {
isDense () {
return this.dense !== undefined
},
sticky () {
if (this.data.weighted && this.data.weighted.sticky) {
return this.data.weighted.sticky
}
if (this.data.loadBalancer && this.data.loadBalancer.sticky) {
return this.data.loadBalancer.sticky
}
return null
},
getProviderLogoPath () {
const name = this.data.provider.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
},
methods: {
status (value) {
if (value === 'enabled') {
return 'positive'
}
if (value === 'disabled') {
return 'negative'
}
return value || 'negative'
},
statusLabel (value) {
if (value === 'enabled') {
return 'success'
}
if (value === 'disabled') {
return 'error'
}
return value || 'error'
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-service-details {
height: 600px;
&-dense{
height: 400px;
}
.q-card__section {
padding: 24px;
+ .q-card__section {
padding-top: 0;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
.q-avatar{
float: left;
}
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-enabled {
color: $positive;
}
&-disabled {
color: $negative;
}
&-warning {
color: $warning;
}
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.provider-logo {
width: 32px;
height: 32px;
img {
width: 100%;
height: 100%;
}
}
}
</style>

View file

@ -1,189 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-tls']"
>
<q-scroll-area
v-if="data"
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section v-if="data">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
TLS
</div>
<boolean-state :value="!!data" />
</div>
</div>
</q-card-section>
<q-card-section v-if="data.options">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
OPTIONS
</div>
<q-chip
dense
class="app-chip app-chip-options"
>
{{ data.options }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="protocol === 'tcp'">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
PASSTHROUGH
</div>
<boolean-state :value="data.passthrough" />
</div>
</div>
</q-card-section>
<q-card-section v-if="data.certResolver">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
CERTIFICATE RESOLVER
</div>
<q-chip
dense
class="app-chip app-chip-service"
>
{{ data.certResolver }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section v-if="data.domains">
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
DOMAINS
</div>
<div
v-for="(domain, key) in data.domains"
:key="key"
class="flex"
>
<q-chip
dense
class="app-chip app-chip-rule"
>
{{ domain.main }}
</q-chip>
<q-chip
v-for="(sanDomain, sanKey) in domain.sans"
:key="sanKey"
dense
class="app-chip app-chip-entry-points"
>
{{ sanDomain }}
</q-chip>
</div>
</div>
</div>
</q-card-section>
</q-scroll-area>
<q-card-section
v-else
style="height: 100%"
>
<div
class="row items-center"
style="height: 100%"
>
<div class="col-12">
<div class="block-empty" />
<div class="q-pb-lg block-empty-logo">
<img
v-if="$q.dark.isActive"
alt="empty"
src="~assets/middlewares-empty-dark.svg"
>
<img
v-else
alt="empty"
src="~assets/middlewares-empty.svg"
>
</div>
<div class="block-empty-label">
There is no<br>TLS configured
</div>
</div>
</div>
</q-card-section>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
import BooleanState from './BooleanState.vue'
export default defineComponent({
name: 'PanelTLS',
components: {
BooleanState
},
props: {
data: { type: Object, default: undefined, required: false },
protocol: { type: String, default: undefined, required: false }
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-tls {
height: 600px;
.q-card__section {
padding: 24px;
+ .q-card__section {
padding-top: 0;
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.app-chip {
&-entry-points {
display: flex;
height: 100%;
flex-wrap: wrap;
border-width: 0;
margin-bottom: 8px;
:deep(.q-chip__content) {
white-space: normal;
}
}
}
.block-empty {
&-logo {
text-align: center;
}
&-label {
font-size: 20px;
font-weight: 700;
color: #b8b8b8;
text-align: center;
line-height: 1.2;
}
}
}
</style>

View file

@ -1,165 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-services', {'panel-services-dense':isDense}]"
>
<q-scroll-area
:thumb-style="appThumbStyle"
style="height:100%;"
>
<q-card-section>
<div class="row items-start no-wrap">
<div class="col-7">
<div class="text-subtitle2 text-table">
Name
</div>
</div>
<div class="col-3">
<div class="text-subtitle2 text-table">
Weight
</div>
</div>
<div class="col-4">
<div class="text-subtitle2 text-table">
Provider
</div>
</div>
</div>
</q-card-section>
<q-separator />
<div
v-for="(service, index) in data.weighted.services"
:key="index"
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col-7">
<q-chip
dense
class="app-chip app-chip-rule app-chip-overflow"
>
{{ service.name }}
<q-tooltip>{{ service.name }}</q-tooltip>
</q-chip>
</div>
<div class="col-3">
{{ service.weight }}
</div>
<div class="col-4">
<q-avatar>
<q-icon :name="`img:${getProviderLogoPath(service)}`" />
</q-avatar>
</div>
</div>
</q-card-section>
<q-separator />
</div>
</q-scroll-area>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PanelWeightedServices',
components: {},
props: {
data: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined }
},
computed: {
isDense () {
return this.dense !== undefined
}
},
methods: {
getProvider (service) {
const words = service.name.split('@')
if (words.length === 2) {
return words[1]
}
return this.data.provider
},
getProviderLogoPath (service) {
const provider = this.getProvider(service)
const name = provider.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-services {
height: 600px;
&-dense{
height: 400px;
}
.q-card__section {
padding: 12px 24px;
+ .q-card__section {
padding-top: 0;
}
}
.block-right-text{
height: 32px;
line-height: 32px;
&-label{
font-size: 14px;
font-weight: 600;
color: $app-text-grey;
float: left;
margin-left: 10px;
text-transform: capitalize;
&-enabled {
color: $positive;
}
&-disabled {
color: $negative;
}
&-warning {
color: $warning;
}
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
.text-table {
font-size: 14px;
font-weight: 700;
letter-spacing: normal;
text-transform: none;
}
}
</style>

View file

@ -1,46 +0,0 @@
<template>
<q-avatar class="provider-logo">
<q-icon :name="`img:${getLogoPath}`" />
</q-avatar>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
props: {
name: { type: String, default: undefined, required: false }
},
computed: {
getLogoPath () {
const name = this.name.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
}
})
</script>
<style scoped lang="scss">
.provider-logo {
width: 32px;
height: 32px;
img {
width: 100%;
height: 100%;
}
}
</style>

View file

@ -1,79 +0,0 @@
<template>
<div class="panel">
<div
v-if="isOpen"
class="panel-backdrop"
@click="close"
/>
<transition name="slide">
<div
v-if="isOpen"
class="panel-content"
>
<slot />
</div>
</transition>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
props: {
isOpen: Boolean
},
emits: ['onClose'],
methods: {
close () {
this.$emit('onClose')
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/mixins";
.slide-enter-active,
.slide-leave-active {
transition: transform 0.2s ease;
}
.slide-enter,
.slide-leave-to {
transform: translateX(100%);
transition: all 150ms ease-in 0s;
}
.panel-backdrop {
z-index: 3000;
background-color: rgba(255, 255, 255, 0.47);
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
cursor: pointer;
}
.panel-content {
z-index: 9999;
overflow-y: auto;
background-color: white;
position: fixed;
right: 0;
top: 0;
height: 100vh;
padding: 0;
width: 100vw;
border-top-left-radius: 20px;
border-bottom-left-radius: 20px;
box-shadow: 2px 0 6px 0 #000;
@include respond-to(md) {
width: 80vw;
max-width: 1500px;
}
}
</style>

View file

@ -1,90 +0,0 @@
<template>
<span
:style="{ height, width: computedWidth }"
:class="['SkeletonBox']"
/>
</template>
<script>
export default {
name: 'SkeletonBox',
props: {
maxWidth: {
type: Number,
default: 100
},
minWidth: {
type: Number,
default: 80
},
height: {
type: String,
default: '2em'
},
width: {
type: String,
default: null
}
},
computed: {
computedWidth () {
return this.width || `${Math.floor((Math.random() * (this.maxWidth - this.minWidth)) + this.minWidth)}%`
}
}
}
</script>
<style scoped lang="scss">
.SkeletonBox {
display: inline-block;
position: relative;
vertical-align: middle;
overflow: hidden;
background-color: #E0E0E0;
border-radius: 4px;
&.dark{
background-color: #9E9E9E;
}
&::after {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
will-change: transform;
transform: translateX(-100%) translateZ(0);
background-image: linear-gradient(
90deg,
rgba(#fff, 0) 0,
rgba(#fff, 0.2) 20%,
rgba(#fff, 0.5) 60%,
rgba(#fff, 0)
);
// TODO - fix high cpu usage
// animation: shimmer 5s infinite;
content: '';
}
@keyframes shimmer {
from { transform: translateX(-100%) translateZ(0); }
to { transform: translateX(100%) translateZ(0); }
}
}
.body--dark .SkeletonBox {
background-color: #525252;
&.dark {
background-color: #333;
}
&::after {
background-image: linear-gradient(
90deg,
rgba(#5e5e5e, 0) 0,
rgba(#5e5e5e, 0.2) 20%,
rgba(#5e5e5e, 0.5) 60%,
rgba(#5e5e5e, 0)
);
}
}
</style>

View file

@ -1,85 +0,0 @@
<template>
<div>
<q-card-section>
<div class="row items-start no-wrap">
<div class="text-subtitle1">
Sticky: Cookie
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row items-start no-wrap">
<div
v-if="sticky.cookie && sticky.cookie.name"
class="col"
>
<div class="text-subtitle2">
NAME
</div>
<q-chip
dense
class="app-chip app-chip-entry-points"
>
{{ sticky.cookie.name }}
</q-chip>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row items-start no-wrap">
<div class="col">
<div class="text-subtitle2">
SECURE
</div>
<boolean-state :value="sticky.cookie.secure" />
</div>
<div class="col">
<div class="text-subtitle2">
HTTP Only
</div>
<boolean-state :value="sticky.cookie.httpOnly" />
</div>
</div>
</q-card-section>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import BooleanState from './BooleanState.vue'
export default defineComponent({
name: 'StickyServiceDetails',
components: {
BooleanState
},
props: {
sticky: { type: Object, default: undefined, required: false },
dense: { type: Boolean, default: undefined }
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-card__section {
padding: 24px;
+ .q-card__section {
padding-top: 0;
}
}
.text-subtitle2 {
font-size: 11px;
color: $app-text-grey;
line-height: 16px;
margin-bottom: 4px;
text-align: left;
letter-spacing: 2px;
font-weight: 600;
text-transform: uppercase;
}
</style>

View file

@ -1,33 +0,0 @@
<template>
<q-avatar text-color="dark">
<q-icon
v-if="isTLS"
name="eva-shield"
/>
</q-avatar>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'TLSState',
props: {
isTLS: Boolean
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-avatar{
font-size: 32px;
border-radius: 4px;
.q-icon {
color: green;
font-size: 22px;
margin: 0 0 0 1px;
}
}
</style>

View file

@ -1,159 +0,0 @@
<template>
<q-toolbar class="row no-wrap items-center">
<q-tabs
align="left"
inline-label
indicator-color="transparent"
stretch
>
<q-route-tab
:to="`/${protocol}/routers`"
no-caps
:label="`${protocolLabel} Routers`"
>
<q-badge
v-if="routerTotal !== 0"
align="middle"
:label="routerTotal"
class="q-ml-sm"
/>
</q-route-tab>
<q-route-tab
:to="`/${protocol}/services`"
no-caps
:label="`${protocolLabel} Services`"
>
<q-badge
v-if="servicesTotal !== 0"
align="middle"
:label="servicesTotal"
class="q-ml-sm"
/>
</q-route-tab>
<q-route-tab
v-if="protocol !== 'udp'"
:to="`/${protocol}/middlewares`"
no-caps
:label="`${protocolLabel} Middlewares`"
>
<q-badge
v-if="middlewaresTotal !== 0"
align="middle"
:label="middlewaresTotal"
class="q-ml-sm"
/>
</q-route-tab>
</q-tabs>
</q-toolbar>
</template>
<script>
import { defineComponent } from 'vue'
import { useStore, mapActions, mapGetters } from 'vuex'
export default defineComponent({
name: 'ToolBar',
data () {
return {
loadingOverview: true,
intervalRefresh: null,
intervalRefreshTime: 5000
}
},
computed: {
...mapGetters('core', { overviewAll: 'allOverview' }),
protocol () {
return this.$route.meta.protocol
},
protocolLabel () {
return this.protocol.toUpperCase()
},
routerTotal () {
const data = this.overviewAll.items && this.overviewAll.items[`${this.protocol}`]
return (data && data.routers && data.routers.total) || 0
},
servicesTotal () {
const data = this.overviewAll.items && this.overviewAll.items[`${this.protocol}`]
return (data && data.services && data.services.total) || 0
},
middlewaresTotal () {
const data = this.overviewAll.items && this.overviewAll.items[`${this.protocol}`]
return (data && data.middlewares && data.middlewares.total) || 0
}
},
created () {
this.refreshAll()
this.intervalRefresh = setInterval(this.onGetAll, this.intervalRefreshTime)
},
beforeUnmount () {
const $store = useStore()
clearInterval(this.intervalRefresh)
$store.commit('core/getOverviewClear')
},
methods: {
...mapActions('core', { getOverview: 'getOverview' }),
refreshAll () {
this.onGetAll()
},
onGetAll () {
this.getOverview()
.then(body => {
console.log('Success -> toolbar/overview', body)
if (!body) {
this.loadingOverview = false
}
})
.catch(error => {
console.log('Error -> toolbar/overview', error)
})
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-toolbar {
min-height: 48px;
padding: 0;
overflow-x: auto;
overflow-y: hidden;
}
.body--dark .q-toolbar {
background-color: #0e204c;
}
.q-tabs {
:deep(.q-tabs__content) {
.q-tab__label {
color: $app-text-grey;
font-size: 16px;
font-weight: 700;
}
.q-badge {
font-size: 13px;
font-weight: 700;
border-radius: 12px;
text-align: center;
align-items: center;
justify-content: center;
min-width: 30px;
padding: 6px;
color: $app-text-grey;
background-color: rgba( $app-text-grey, .1 );
}
.q-tab--active {
.q-tab__label {
color: $accent;
}
.q-badge {
color: $accent;
background-color: rgba( $accent, .1 );
}
}
}
}
</style>

View file

@ -1,126 +0,0 @@
<template>
<q-toolbar class="row no-wrap items-center">
<q-btn-toggle
v-model="getStatus"
class="bar-toggle"
toggle-color="app-toggle"
text-color="app-grey"
size="14px"
no-caps
rounded
unelevated
:options="[
{label: 'All Status', value: ''},
{label: 'Success', value: 'enabled'},
{label: 'Warnings', value: 'warning'},
{label: 'Errors', value: 'disabled'}
]"
/>
<q-space />
<q-input
v-model="getFilter"
rounded
dense
outlined
type="search"
debounce="500"
placeholder="Search"
:bg-color="$q.dark.isActive ? undefined : 'white'"
class="bar-search"
>
<template #append>
<q-icon name="eva-search-outline" />
</template>
</q-input>
</q-toolbar>
</template>
<script>
import { defineComponent } from 'vue'
import Helps from '../../_helpers/Helps'
export default defineComponent({
name: 'ToolBarTable',
props: {
status: { type: String, default: undefined, required: false },
filter: { type: String, default: undefined, required: false }
},
emits: ['update:status', 'update:filter'],
computed: {
getStatus: {
get () {
return this.status
},
set (newValue) {
this.$emit('update:status', newValue)
this.stateToRoute(this.$route, { status: newValue })
}
},
getFilter: {
get () {
return this.filter
},
set (newValue) {
this.$emit('update:filter', newValue)
this.stateToRoute(this.$route, { filter: newValue })
}
}
},
watch: {
$route (to, from) {
this.routeToState(to)
}
},
mounted () {
this.routeToState(this.$route)
},
methods: {
routeToState (route) {
for (const query in route.query) {
this.$emit(`update:${query}`, route.query[query])
}
},
stateToRoute (route, values) {
this.$router.push({
path: route.path,
query: Helps.removeEmptyObjects({
...route.query,
...values
})
})
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.q-toolbar {
padding: 0;
:deep(.bar-toggle) {
.q-btn {
font-weight: 600;
margin-right: 12px;
&.q-btn--rounded {
border-radius: 12px;
}
&.bg-app-toggle {
color: $accent !important;
}
.body--dark &.bg-app-toggle {
color: lighten($accent, 25%) !important;
}
}
}
:deep(.bar-search) {
.q-field__inner {
.q-field__control {
border-radius: 12px;
}
}
}
}
</style>

View file

@ -0,0 +1,33 @@
/*
Copyright (C) 2022-2024 Traefik Labs
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Button, Flex, Text } from '@traefiklabs/faency'
import { ComponentProps, ReactNode } from 'react'
type IconButtonProps = ComponentProps<typeof Button> & {
gap?: 1 | 2
icon: ReactNode
text?: string
}
export default function IconButton({ css = {}, gap = 2, icon, text, ...props }: IconButtonProps) {
return (
<Button variant="primary" size="large" css={{ borderRadius: 0, ...css }} {...props}>
<Flex align="center" justify="between" gap={gap}>
{icon}
{text && <Text css={{ color: 'currentColor', paddingTop: '1px' }}>{text}</Text>}
</Flex>
</Button>
)
}

View file

@ -0,0 +1,49 @@
import { styled, Flex, Label } from '@traefiklabs/faency'
import { ComponentProps } from 'react'
import SortIcon from 'components/icons/SortIcon'
const StyledSortButton = styled('button', {
border: 'none',
margin: 0,
padding: 0,
overflow: 'visible',
background: 'transparent',
color: 'inherit',
font: 'inherit',
verticalAlign: 'middle',
lineHeight: 'normal',
'-webkit-font-smoothing': 'inherit', // @FIXME not on standard tracks https://developer.mozilla.org/en-US/docs/Web/CSS/font-smooth
'-moz-osx-font-smoothing': 'inherit',
'-webkit-appearance': 'none',
'&:focus': {
outline: 0,
color: '$hiContrast',
},
'&::-moz-focus-inner': {
// @FIXME not on standard tracks https://developer.mozilla.org/en-US/docs/Web/CSS/::-moz-focus-inner
border: 0,
padding: 0,
},
'@hover': {
'&:hover': {
cursor: 'pointer',
color: '$hiContrast',
},
},
})
export default function SortButton({
label,
order,
...props
}: ComponentProps<typeof StyledSortButton> & { order?: 'asc' | 'desc' | ''; label?: string }) {
return (
<StyledSortButton type="button" {...props}>
<Flex align="center">
{label && <Label css={{ cursor: 'inherit', color: 'inherit' }}>{label}</Label>}
<SortIcon height={15} css={{ ml: '$2' }} order={order} />
</Flex>
</StyledSortButton>
)
}

View file

@ -1,257 +0,0 @@
<template>
<q-card
flat
bordered
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-h6 text-weight-bold">
{{ getName }}
</div>
</div>
<div class="col-auto">
<q-btn
:to="getUrl"
color="accent"
dense
flat
icon-right="eva-arrow-forward-outline"
no-caps
label="Explore"
size="md"
class="text-weight-bold"
/>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="row items-center q-col-gutter-md">
<div class="col-12 col-sm-6">
<Doughnut
:data="getChartdata()"
:options="options"
/>
</div>
<div class="col-12 col-sm-6">
<q-list>
<q-item class="label-state">
<q-item-section avatar>
<avatar-state state="positive" />
</q-item-section>
<q-item-section class="label-state-text">
<q-item-label>Success</q-item-label>
<q-item-label
caption
lines="1"
>
{{ getSuccess(true) }}%
</q-item-label>
</q-item-section>
<q-item-section
side
class="label-state-side"
>
{{ getSuccess() }}
</q-item-section>
</q-item>
<q-item class="label-state">
<q-item-section avatar>
<avatar-state state="warning" />
</q-item-section>
<q-item-section class="label-state-text">
<q-item-label>Warnings</q-item-label>
<q-item-label
caption
lines="1"
>
{{ getWarnings(true) }}%
</q-item-label>
</q-item-section>
<q-item-section
side
class="label-state-side"
>
{{ getWarnings() }}
</q-item-section>
</q-item>
<q-item class="label-state">
<q-item-section avatar>
<avatar-state state="negative" />
</q-item-section>
<q-item-section class="label-state-text">
<q-item-label>Errors</q-item-label>
<q-item-label
caption
lines="1"
>
{{ getErrors(true) }}%
</q-item-label>
</q-item-section>
<q-item-section
side
class="label-state-side"
>
{{ getErrors() }}
</q-item-section>
</q-item>
</q-list>
</div>
</div>
</q-card-section>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
import Helps from '../../_helpers/Helps'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import AvatarState from '../_commons/AvatarState.vue'
ChartJS.register(ArcElement, Tooltip, Legend)
export default defineComponent({
name: 'PanelChart',
components: {
Doughnut,
AvatarState
},
props: {
name: { type: String, default: undefined, required: false },
data: { type: Object, default: undefined, required: false },
type: { type: String, default: undefined, required: false }
},
data () {
return {
loading: true,
options: {
plugins: {
legend: {
display: false
}
},
animation: {
duration: 1000
},
tooltips: {
enabled: true
}
}
}
},
computed: {
getName () {
return Helps.capFirstLetter(this.name)
},
getUrl () {
return `/${this.type}/${this.getName.toLowerCase()}`
}
},
methods: {
getSuccess (inPercent = false) {
const num = this.data.total - (this.data.errors + this.data.warnings)
let result = 0
if (inPercent) {
result = Helps.getPercent(num, this.data.total).toFixed(0)
} else {
result = num
}
return isNaN(result) || result < 0 ? 0 : result
},
getWarnings (inPercent = false) {
const num = this.data.warnings
let result = 0
if (inPercent) {
result = Helps.getPercent(num, this.data.total).toFixed(0)
} else {
result = num
}
return isNaN(result) || result < 0 ? 0 : result
},
getErrors (inPercent = false) {
const num = this.data.errors
let result = 0
if (inPercent) {
result = Helps.getPercent(num, this.data.total).toFixed(0)
} else {
result = num
}
return isNaN(result) || result < 0 ? 0 : result
},
getData () {
return [this.getSuccess(), this.getWarnings(), this.getErrors()]
},
getChartdata () {
if (this.getData()[0] === 0 && this.getData()[1] === 0 && this.getData()[2] === 0) {
this.options.tooltips.enabled = false
return {
datasets: [{
backgroundColor: [
this.$q.dark.isActive ? '#2d2d2d' : '#f2f3f5'
],
borderColor: [
this.$q.dark.isActive ? '#1d1d1d' : '#fff'
],
data: [1]
}]
}
} else {
this.options.tooltips.enabled = true
return {
datasets: [{
backgroundColor: [
'#00a697',
'#db7d11',
'#ff0039'
],
borderColor: [
this.$q.dark.isActive ? '#1d1d1d' : '#fff',
this.$q.dark.isActive ? '#1d1d1d' : '#fff',
this.$q.dark.isActive ? '#1d1d1d' : '#fff'
],
data: this.getData()
}],
labels: [
'Success',
'Warnings',
'Errors'
]
}
}
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.label-state {
min-height: 32px;
padding: 8px;
.q-item__section--avatar{
min-width: 32px;
padding: 0 8px 0 0;
}
&-text{
.q-item__label{
font-size: 16px;
line-height: 16px !important;
font-weight: 600;
}
.q-item__label--caption{
font-size: 14px;
line-height: 14px !important;
font-weight: 500;
color: $app-text-grey;
}
}
&-side{
font-size: 22px;
font-weight: 700;
padding: 0 0 0 8px;
color: inherit;
}
}
</style>

View file

@ -1,73 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-entry', {'panel-entry-detail':type === 'detail'}, {'panel-entry-focus':focus}, {'panel-entry-ex-size':exSize}]"
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-subtitle2">
{{ name }}
</div>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="text-h3 text-center text-weight-bold ellipsis">
<span>{{ address }}</span>
<q-tooltip>{{ address }}</q-tooltip>
</div>
</q-card-section>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PanelEntry',
props: {
address: { type: String, default: undefined, required: false },
name: { type: String, default: undefined, required: false },
type: { type: String, default: undefined, required: false },
focus: Boolean,
exSize: { type: Number, default: undefined, required: false }
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-entry {
.text-subtitle2 {
font-weight: 600;
letter-spacing: 3px;
color: $app-text-grey;
text-transform: uppercase;
text-align: center;
}
&-detail{
.text-subtitle2 {
font-size: 11px;
font-weight: 600;
line-height: 11px;
text-align: left;
}
.text-h3 {
font-size: 16px;
text-align: left;
line-height: 16px;
}
}
&-focus {
border: solid 2px $accent;
}
&-ex-size {
.text-h3 {
font-size: 22px;
}
}
}
</style>

View file

@ -1,108 +0,0 @@
<template>
<q-card
flat
bordered
:class="['panel-feature']"
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col">
<div class="text-subtitle2">
{{ featureKey }}
</div>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="text-h3 text-center text-weight-bold">
<q-chip
:class="['feature-chip', {'feature-chip-string':isString}, {'feature-chip-boolean':isBoolean}, {'feature-chip-boolean-true':isTrue}]"
>
{{ getVal }}
</q-chip>
</div>
</q-card-section>
</q-card>
</template>
<script>
export default {
name: 'PanelFeature',
props: {
featureKey: { type: String, default: undefined, required: false },
featureVal: { type: [String, Boolean], default: undefined, required: false }
},
computed: {
isString () {
return typeof this.featureVal === 'string'
},
isBoolean () {
return typeof this.featureVal === 'boolean' || this.featureVal === ''
},
isTrue () {
return this.isBoolean && this.featureVal === true
},
getVal () {
if (this.featureVal === true) {
return 'ON'
} else if (this.featureVal === false || this.featureVal === '') {
return 'OFF'
} else {
return this.featureVal
}
}
}
}
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.panel-feature {
.text-subtitle2 {
font-weight: 600;
letter-spacing: 3px;
color: $app-text-grey;
text-transform: uppercase;
text-align: center;
}
}
.feature-chip {
border-radius: 12px;
border-width: 2px;
height: 56px;
padding: 12px 24px;
color: $primary;
&-string{
border-color: $app-text-grey;
font-size: 20px;
color: $app-text-grey;
background-color: rgba( $app-text-grey, .1 );
}
&-boolean{
font-size: 40px;
font-weight: 700;
border-color: $negative;
color: $negative;
background-color: rgba( $negative, .1 );
&-true{
border-color: $positive;
color: $positive;
background-color: rgba( $positive, .1 );
}
}
}
.body--dark {
.feature-chip-string {
background-color: rgba( $app-text-grey, .3 );
}
.feature-chip-boolean {
background-color: rgba( $negative, .3 );
&-true {
background-color: rgba( $positive, .3 );
}
}
}
</style>

View file

@ -1,75 +0,0 @@
<template>
<q-card
flat
bordered
>
<q-card-section>
<div class="row items-center no-wrap">
<div class="col text-center">
<q-avatar
class="provider-logo"
font-size="inherit"
>
<q-icon :name="`img:${getLogoPath}`" />
</q-avatar>
</div>
</div>
</q-card-section>
<q-card-section>
<div class="text-h6 text-center text-weight-bold">
{{ getName }}
</div>
</q-card-section>
</q-card>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PanelProvider',
props: {
name: {
type: String,
default: '',
required: false
}
},
computed: {
getName () {
return this.name
},
getLogoPath () {
const name = this.getName.toLowerCase()
if (name.startsWith('plugin-')) {
return 'providers/plugin.svg'
}
if (name.startsWith('consul-')) {
return 'providers/consul.svg'
}
if (name.startsWith('consulcatalog-')) {
return 'providers/consulcatalog.svg'
}
if (name.startsWith('nomad-')) {
return 'providers/nomad.svg'
}
return `providers/${name}.svg`
}
}
})
</script>
<style scoped lang="scss">
@import "../../css/sass/variables";
.provider-logo {
width: 52px;
height: 52px;
img {
width: 100%;
height: 100%;
}
}
</style>

View file

@ -0,0 +1,17 @@
export const AutoThemeIcon = ({ size = 20 }) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
stroke="currentColor"
strokeWidth="1"
>
<g id="SVGRepo_bgCarrier" strokeWidth="0"></g>
<g id="SVGRepo_tracerCarrier" strokeLinecap="round" strokeLinejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path d="M12 2.2a9.8 9.8 0 1 0 9.8 9.8A9.81 9.81 0 0 0 12 2.2zM3.2 12A8.81 8.81 0 0 1 12 3.2v17.6A8.81 8.81 0 0 1 3.2 12z"></path>
</g>
</svg>
)

View file

@ -0,0 +1,26 @@
import { useIsDarkMode } from 'hooks/use-theme'
export const EmptyIcon = () => {
const isDarkMode = useIsDarkMode()
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 88 96" preserveAspectRatio="xMidYMid meet">
<g fill="none" fillRule="nonzero" stroke="#DCDCDC" strokeWidth="4">
<path
fill={isDarkMode ? 'hsl(209, 28%, 19%)' : '#f2f3f5'}
d="M41.05 42.807zm0 0l-.006.003 1.917 3.508-1.876-3.532c1.866-1.05 4.007-1.044 5.82-.006 8.058 4.62 22.413 13.097 37.112 21.84C85.14 65.299 86 66.39 86 68c0 1.612-.866 2.71-1.993 3.383-15.424 9.173-29.305 17.368-37.1 21.836a5.813 5.813 0 0 1-5.812 0C33.322 88.765 19.88 80.83 3.983 71.38 2.86 70.7 2 69.61 2 67.999c0-1.615.869-2.72 1.993-3.382a8052.42 8052.42 0 0 1 17.004-10.023l.05-.03a33723.823 33723.823 0 0 1 18.288-10.75l1.27-.746.334-.196.085-.05.022-.013.004-.002z"
/>
<path
fill={isDarkMode ? 'hsl(208, 37%, 15%)' : 'hsl(0, 0%, 99%)'}
strokeDasharray="0,10"
strokeLinecap="round"
d="M4 48c0-.96.707-1.483 1.012-1.662 9.254-5.48 37.058-21.81 37.054-21.809 1.274-.717 2.664-.69 3.844-.014 10.211 5.854 30.961 18.182 37.074 21.819.305.183 1.016.71 1.016 1.666 0 .955-.71 1.482-1.016 1.664-7.914 4.707-27.37 16.26-37.07 21.818a3.813 3.813 0 0 1-3.824.002c-9.703-5.56-29.16-17.113-37.074-21.818C4.71 49.482 4 48.955 4 48z"
/>
<path
fill={isDarkMode ? 'hsl(209, 28%, 19%)' : '#f2f3f5'}
d="M41.044 2.81L41.05 2.807L41.046 2.809L41.024 2.822L40.939 2.872L40.605 3.068L39.335 3.814C33.2379 7.39542 27.1419 10.9787 21.047 14.564L20.997 14.594C15.3249 17.9281 9.6569 21.2691 3.993 24.617C2.869 25.279 2 26.384 2 27.999C2 29.61 2.86 30.7 3.983 31.38C19.88 40.83 33.322 48.765 41.095 53.219C41.9786 53.729 42.9808 53.9975 44.001 53.9975C45.0212 53.9975 46.0234 53.729 46.907 53.219C54.702 48.751 68.583 40.556 84.007 31.383C85.134 30.71 86 29.612 86 28C86 26.39 85.14 25.299 84.017 24.62C69.318 15.877 54.963 7.4 46.905 2.78C45.092 1.742 42.951 1.736 41.085 2.786"
/>
</g>
</svg>
)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,75 @@
import { config, Flex } from '@traefiklabs/faency'
import { useEffect, useState } from 'react'
import { CustomIconProps } from 'components/icons'
import { useIsDarkMode } from 'hooks/use-theme'
type SortIconProps = CustomIconProps & {
order?: 'asc' | 'desc' | ''
}
export default function SortIcon({ css = {}, order, flexProps = {}, ...props }: SortIconProps) {
const [enabledColor, setEnabledColor] = useState<string>((config.theme.colors as Record<string, string>).deepBlue3)
const [disabledColor, setDisabledColor] = useState<string>((config.theme.colors as Record<string, string>).deepBlue8)
const isDarkMode = useIsDarkMode()
useEffect(() => {
setEnabledColor((config.theme.colors as Record<string, string>)[isDarkMode ? 'deepBlue3' : 'deepBlue11'])
setDisabledColor((config.theme.colors as Record<string, string>)[isDarkMode ? 'deepBlue8' : 'deepBlue6'])
}, [isDarkMode])
return (
<Flex {...flexProps} css={css}>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
role="img"
aria-labelledby="sort-icon"
viewBox="0 0 8 15"
{...props}
>
<title id="sort-icon">Sort</title>
<g fill="none" fillRule="evenodd" stroke="none" strokeWidth="1">
<g transform="translate(-438 -204)">
<g transform="translate(368 190)">
<g>
<path d="M0 0H217V40H0z"></path>
</g>
<text
fill="#000"
fillOpacity="0.85"
fontFamily="RubikRoman-Medium, Rubik"
fontSize="16"
fontWeight="400"
letterSpacing="0.615"
>
<tspan x="17" y="26.057">
Name
</tspan>
</text>
</g>
<g>
<g transform="translate(435 201.557)">
<g transform="translate(3 12)">
<path
fill={!!order && order === 'desc' ? enabledColor : disabledColor}
d="M7.815.2a.72.72 0 010 .964L4.447 4.8a.6.6 0 01-.894 0L.185 1.164a.72.72 0 010-.964.6.6 0 01.893 0L4 3.354 6.922.2a.6.6 0 01.893 0z"
></path>
</g>
</g>
<g transform="translate(435 201.557)">
<g transform="rotate(180 5.5 4)">
<path
fill={!!order && order === 'asc' ? enabledColor : disabledColor}
d="M7.815.2a.72.72 0 010 .964L4.447 4.8a.6.6 0 01-.894 0L.185 1.164a.72.72 0 010-.964.6.6 0 01.893 0L4 3.354 6.922.2a.6.6 0 01.893 0z"
></path>
</g>
</g>
</g>
</g>
</g>
</svg>
</Flex>
)
}

View file

@ -0,0 +1,13 @@
import { CSS, Flex, VariantProps } from '@traefiklabs/faency'
import { HTMLAttributes } from 'react'
export type CustomIconProps = HTMLAttributes<SVGElement> & {
color?: string
fill?: string
stroke?: string
width?: number | string
height?: number | string
flexProps?: VariantProps<typeof Flex>
css?: CSS
viewBox?: string
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Consul(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2126_2)">
<path
d="M35.2553 42.0171C32.0334 42.0206 29.4166 39.4224 29.4076 36.2091C29.3985 32.9964 32.0006 30.3829 35.2231 30.369C38.4449 30.355 41.0694 32.9455 41.089 36.1582C41.103 37.7087 40.4931 39.1992 39.3977 40.3013C38.3016 41.4012 36.8095 42.0192 35.2553 42.0171ZM46.3722 39.0502C44.8851 39.0502 43.6792 37.8478 43.6792 36.3649C43.6792 34.882 44.8851 33.6795 46.3722 33.6795C47.8594 33.6795 49.0653 34.882 49.0653 36.3649C49.0674 37.0777 48.7841 37.762 48.2784 38.2656C47.7733 38.7698 47.0871 39.0523 46.3722 39.0502ZM56.228 41.5821C55.8097 42.9185 54.3954 43.6767 53.0468 43.2875C51.6982 42.8983 50.9084 41.5047 51.2701 40.1516C51.6324 38.7984 53.0118 37.9824 54.3758 38.3123C55.7412 38.645 56.5883 40.0037 56.284 41.3708C56.284 41.4405 56.284 41.5054 56.228 41.5821ZM54.3254 34.6609C53.2622 34.926 52.1444 34.5249 51.4953 33.6447C50.8462 32.7658 50.7944 31.5822 51.3638 30.6496C51.9339 29.7164 53.0125 29.2198 54.0946 29.3906C55.176 29.5636 56.0462 30.3699 56.298 31.4329C56.3651 31.7816 56.3651 32.1374 56.298 32.4861C56.1511 33.5672 55.3397 34.4412 54.2695 34.6693L54.3254 34.6609ZM63.9084 41.2885C63.651 42.7483 62.2548 43.7241 60.7908 43.4682C59.3261 43.2115 58.3475 41.8207 58.6035 40.3601C58.8595 38.8982 60.2543 37.9217 61.719 38.177C63.1089 38.3974 64.0973 39.6431 63.9924 41.0437C63.9602 41.1183 63.9413 41.1985 63.9364 41.2808L63.9084 41.2885ZM61.712 34.5444C60.2487 34.7865 58.8651 33.7995 58.6231 32.3403C58.3804 30.8826 59.3687 29.5029 60.8321 29.2602C62.294 29.0175 63.679 30.003 63.9224 31.4615C63.9504 31.6931 63.9504 31.9274 63.9224 32.159C63.8133 33.3615 62.9061 34.3408 61.712 34.5444ZM59.8374 50.5868C59.1589 51.8004 57.6711 52.3096 56.3889 51.7669C55.1047 51.2229 54.4387 49.8028 54.8431 48.472C55.2488 47.1419 56.5953 46.3307 57.9649 46.5908C59.3345 46.8531 60.2872 48.1009 60.1732 49.4854C60.1522 49.876 60.0368 50.252 59.8374 50.5868ZM58.8651 25.9101C57.5655 26.6397 55.9203 26.1808 55.1886 24.8848C54.4569 23.5896 54.9172 21.9484 56.2169 21.2188C57.5151 20.4892 59.1617 20.9468 59.8934 22.242C60.1711 22.7114 60.2893 23.2569 60.2291 23.7974C60.1473 24.6749 59.6401 25.4561 58.8721 25.8899L58.8651 25.9101ZM35.5126 62.9965C25.7366 63.1534 16.6348 58.0422 11.7006 49.6255C6.76645 41.2082 6.76645 30.7918 11.7006 22.3744C16.6348 13.9585 25.7366 8.84724 35.5126 9.00348C41.4611 8.99372 47.2431 10.9565 51.9506 14.5834L48.642 18.887C42.1088 13.8999 33.3037 13.042 25.9261 16.6745C18.5486 20.3064 13.8775 27.7995 13.8775 36.0035C13.8817 44.2081 18.5514 51.7006 25.9268 55.338C33.3023 58.9719 42.1088 58.1245 48.649 53.1479L51.9506 57.4584C47.2375 61.0686 41.4555 63.016 35.5126 62.9965Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2126_2">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,27 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Docker(props: ProviderIconProps) {
return (
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="docker"
{...props}
>
<g clipPath="url(#clip0_2126_9)">
<path
d="M63.0889 32.078C61.7158 31.1576 58.1084 30.7648 55.4862 31.4684C55.3454 28.868 53.9971 26.6764 51.5333 24.764L50.6213 24.1548L50.0134 25.0692C48.8187 26.8744 48.3147 29.2808 48.4931 31.4684C48.6339 32.8164 49.1043 34.3308 50.0134 35.43C46.6004 37.4012 43.4534 36.9536 29.5188 36.9536H8.00475C7.94236 40.0864 8.44793 46.1112 12.2965 51.016C12.722 51.558 13.1876 52.082 13.694 52.5868C16.8233 55.7064 21.5514 57.994 28.6213 58C39.4073 58.01 48.6479 52.2056 54.2695 38.1732C56.119 38.2028 61.0022 38.5032 63.392 33.9064C63.4504 33.8292 64 32.688 64 32.688L63.0889 32.0788V32.078ZM22.0454 29.2136H15.9954V35.236H22.0454V29.2136ZM29.86 29.2136H23.8104V35.236H29.86V29.2136ZM37.6759 29.2136H31.6263V35.236H37.6759V29.2136ZM45.4921 29.2136H39.4421V35.236H45.4921V29.2136ZM14.2291 29.2136H8.17955V35.236H14.2291V29.2136ZM22.0454 21.6072H15.9954V27.6296H22.0454V21.6072ZM29.86 21.6072H23.8104V27.6296H29.86V21.6072ZM37.6759 21.6072H31.6263V27.6296H37.6759V21.6072ZM37.6759 14H31.6263V20.0224H37.6759V14Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2126_9">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,27 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function ECS(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_19)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M36.1004 58.833L14.3476 47.8769V23.3568L30.945 13.5687V20.8452L21.8194 26.5566C21.3374 26.8611 21.0428 27.3872 21.0428 27.9564V43.1681C21.0428 43.7968 21.401 44.3692 21.9734 44.6504L35.1729 51.1163C35.6417 51.348 36.2006 51.3414 36.6727 51.1064L47.0334 45.8946L53.9932 51.6922L36.1004 58.833ZM48.339 42.6585C47.8301 42.2316 47.0971 42.1521 46.5012 42.4498L35.9027 47.7807L24.3904 42.1422V28.863L33.5125 23.1517C33.998 22.8472 34.2925 22.3211 34.2925 21.7519V10.6567C34.2925 10.0611 33.9678 9.51178 33.4457 9.21728C32.9168 8.91948 32.2706 8.92941 31.7617 9.23713L11.8168 20.9942C11.3113 21.292 11 21.8313 11 22.417V48.8893C11 49.5114 11.3515 50.0805 11.9139 50.362L35.26 62.1222C35.4943 62.2412 35.7555 62.3009 36.0199 62.3009C36.2308 62.3009 36.4418 62.2613 36.6458 62.1818L57.8261 53.7339C58.3685 53.5153 58.7601 53.0323 58.8538 52.4564C58.9444 51.8841 58.7266 51.3049 58.2781 50.931L48.339 42.6585Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M57.6522 42.4493L51.1279 37.3832V27.9592C51.1279 27.3867 50.8299 26.854 50.3378 26.5528L40.998 20.8315V13.5583L57.6522 23.3067V42.4493ZM60.1797 20.9407L40.1779 9.23003C39.6624 8.92891 39.0162 8.92231 38.4941 9.21681C37.975 9.51131 37.6504 10.0573 37.6504 10.6529V21.7514C37.6504 22.3239 37.9517 22.8567 38.4405 23.1578L47.7801 28.8791V38.1873C47.7801 38.6937 48.0179 39.1736 48.423 39.4878L58.2918 47.1583C58.5931 47.3931 58.9545 47.5124 59.3261 47.5124C59.5738 47.5124 59.8248 47.4561 60.0558 47.3436C60.6317 47.069 61 46.4898 61 45.8578V22.3636C61 21.7779 60.6886 21.2352 60.1797 20.9407Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_19">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Etcd(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_29)">
<path
d="M33.4545 33.8225C33.4545 35.7547 31.8862 37.3176 29.9531 37.3176C28.0186 37.3176 26.4545 35.7549 26.4545 33.8225C26.4545 31.8984 28.0189 30.3294 29.9531 30.3294C31.8862 30.3294 33.4545 31.8984 33.4545 33.8225ZM38.5455 33.8228C38.5455 31.8982 40.1138 30.3294 42.0465 30.3294C43.9788 30.3294 45.5455 31.8982 45.5455 33.8228C45.5455 35.7551 43.9788 37.3176 42.0465 37.3176C40.1138 37.3176 38.5455 35.7549 38.5455 33.8228ZM63.2095 37.962L64 37.9013L63.9246 38.7015C63.5364 42.7513 62.2748 46.6091 60.1746 50.1674L59.7674 50.8591L59.153 50.3394C57.7203 49.1304 55.9901 48.3316 54.1549 48.0118C52.9469 50.4764 51.5163 52.7904 49.8932 54.9392C47.3154 55.8397 44.6297 56.5124 41.8568 56.9139C41.5898 58.7519 41.8024 60.655 42.5268 62.4125L42.8339 63.1587L42.0454 63.3317L36.0029 64C34.0104 64 31.9756 63.7745 29.9586 63.3317L29.1709 63.1587L29.4772 62.414C30.2036 60.6565 30.4162 58.7557 30.1492 56.9179C27.3653 56.5167 24.6695 55.8424 22.0822 54.9392C20.4607 52.7921 19.0313 50.4796 17.8253 48.0184C15.9959 48.3401 14.2754 49.1384 12.8515 50.3439L12.2371 50.8644L11.8279 50.1709C9.73147 46.6171 8.46962 42.7591 8.07539 38.7028L8 37.901L8.8045 37.9623C9.06273 37.9838 9.32847 37.994 9.6125 37.994C11.2305 37.994 12.8019 37.617 14.2206 36.9253C13.7437 34.17 13.5521 31.4119 13.6122 28.6852C15.1566 26.4639 16.9211 24.3651 18.9091 22.4329C18.0455 20.8226 16.7701 19.4378 15.1786 18.4538L14.4938 18.0308L15.0296 17.4283C17.7366 14.381 21.1117 11.9248 24.7888 10.3255L25.5286 10.0045L25.7185 10.7878C26.1608 12.6085 27.0868 14.2518 28.3559 15.5721C30.8042 14.2745 33.3585 13.2273 35.9909 12.4335C38.6295 13.229 41.1883 14.2793 43.6376 15.5788C44.912 14.2568 45.8402 12.6095 46.2843 10.7835L46.4729 10L47.2155 10.3225C50.9394 11.9453 54.2241 14.3368 56.9762 17.4301L57.5104 18.0313L56.8249 18.4543C55.2284 19.4421 53.9495 20.8351 53.0844 22.4541C55.0784 24.3939 56.8449 26.4994 58.3906 28.7309C58.4434 31.4589 58.2415 34.197 57.7669 36.9147C59.1926 37.613 60.7727 37.9943 62.4015 37.9943C62.6823 37.9943 62.9465 37.984 63.2095 37.962ZM44.6385 47.7649C46.2106 45.3301 47.4557 42.7138 48.3466 39.9685C49.2375 37.24 49.7643 34.3932 49.9225 31.4617C48.0658 29.1672 45.9554 27.1542 43.6248 25.4636C41.2712 23.7581 38.7094 22.3686 35.9909 21.3216C33.2678 22.3684 30.7013 23.7599 28.3421 25.4726C26.0205 27.1542 23.9194 29.1517 22.0717 31.4319C22.2227 34.3802 22.7432 37.2435 23.6326 39.9765C24.5258 42.7223 25.7668 45.3303 27.3365 47.7649C30.1976 48.5411 33.0972 48.9326 35.9909 48.9326C38.8767 48.9326 41.7799 48.5409 44.6385 47.7649Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_29">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,27 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function File(props: ProviderIconProps) {
return (
<svg
width="72"
height="72"
viewBox="0 0 72 72"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-testid="file"
{...props}
>
<g clipPath="url(#clip0_2127_33)">
<path
d="M45.9 24C44.4096 24 43.2 22.656 43.2 21V13.2C43.1989 13.1052 43.2247 13.0121 43.2744 12.9314C43.3241 12.8507 43.3957 12.7857 43.4808 12.744C43.5633 12.7077 43.6553 12.6991 43.7431 12.7194C43.831 12.7397 43.9098 12.7879 43.968 12.8568L53.2392 23.1456C53.3688 23.2896 53.4072 23.5056 53.3352 23.6904C53.3061 23.7784 53.2507 23.8553 53.1764 23.9107C53.1021 23.9661 53.0126 23.9973 52.92 24H45.9ZM53.5512 25.9992C53.6712 25.9992 53.784 26.052 53.868 26.148C53.9539 26.2454 54.0009 26.371 54 26.5008V55.9992C54 58.2072 52.3872 60 50.4 60H21.6C19.6128 60 18 58.2096 18 55.9992V16.0008C18 13.7928 19.6128 12 21.6 12H40.9512C41.0712 12 41.184 12.0528 41.268 12.1464C41.3544 12.2444 41.4015 12.3709 41.4 12.5016V21C41.4 23.76 43.416 25.9992 45.9 25.9992H53.5512Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_33">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Http(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_37)">
<path
d="M43.328 57.1467C47.1409 51.7373 49.4221 45.3985 49.9307 38.8H58.2027C57.6746 42.9388 56.0028 46.8483 53.3752 50.0893C50.7475 53.3302 47.2682 55.7742 43.328 57.1467ZM13.792 38.8H22.1707C22.6333 45.411 24.9055 51.7673 28.7387 57.1733C24.7819 55.8091 21.2854 53.3657 18.6442 50.1189C16.003 46.8722 14.3223 42.9516 13.792 38.8V38.8ZM28.8267 14.8C24.9921 20.2215 22.6959 26.5795 22.1813 33.2H13.792C14.3243 29.0331 16.0157 25.0994 18.6737 21.8464C21.3317 18.5935 24.8495 16.1521 28.8267 14.8V14.8ZM27.8213 33.2C28.6773 24.0373 33.3653 17.6613 36.072 14.712C38.872 17.7093 43.4853 24.0293 44.2853 33.2H27.8213V33.2ZM27.824 38.8H44.2933C43.4373 47.9733 38.7387 54.3547 36.0347 57.3013C31.2951 52.233 28.4025 45.715 27.824 38.8ZM58.2053 33.2H49.9387C49.4801 26.6156 47.225 20.2829 43.4187 14.8907C47.3374 16.273 50.7949 18.7171 53.4055 21.9501C56.016 25.1832 57.6769 29.078 58.2027 33.2H58.2053ZM64 36C64 20.5867 51.48 8.05067 36.08 8.00533H36.0427L36 8C20.56 8 8 20.5627 8 36C8 51.44 20.56 64 36 64L36.0427 63.9973L36.056 64L36.0773 63.9947C51.48 63.952 64 51.4107 64 36Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_37">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Hub(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_41)">
<path
d="M36.8685 14.2311L47.9526 20.3141C48.2542 20.4802 48.5058 20.7255 48.6809 21.0243C48.856 21.323 48.9481 21.6641 48.9475 22.0114L48.9383 31.8109L59.0051 37.3356C59.3068 37.5017 59.5585 37.7472 59.7336 38.0461C59.9087 38.345 60.0008 38.6861 60 39.0336L59.9895 49.9661C59.9897 50.3123 59.8978 50.6521 59.7235 50.9499C59.5491 51.2477 59.2987 51.4926 58.9986 51.6588L47.9244 57.767C47.6447 57.9213 47.331 58.0015 47.0124 58C46.6938 57.9985 46.3808 57.9153 46.1026 57.7583L36.0758 52.1082L25.8186 57.767C25.5391 57.9212 25.2254 58.0013 24.907 57.9998C24.5885 57.9983 24.2757 57.9152 23.9975 57.7583L12.9765 51.5479C12.6802 51.3804 12.4334 51.1358 12.2617 50.8395C12.0901 50.5433 11.9997 50.206 12 49.8626V39.0575C12 38.3519 12.3787 37.7027 12.9877 37.3648L23.0525 31.787V22.036C23.0525 21.3297 23.4312 20.6812 24.0402 20.3433L35.0612 14.2351C35.338 14.0815 35.6484 14.0007 35.9641 14C36.2797 13.9993 36.5911 14.0788 36.8685 14.2311ZM46.4268 35.4757L38.5538 39.8367C38.354 39.9478 38.1875 40.1111 38.0715 40.3097C37.9556 40.5082 37.8946 40.7346 37.8949 40.9652V47.9708C37.8949 48.4381 38.1443 48.8682 38.5459 49.0946L46.4248 53.534C46.8022 53.7464 47.2602 53.7491 47.6389 53.54L55.5414 49.1815C55.7416 49.0707 55.9085 48.9075 56.0248 48.709C56.1411 48.5104 56.2024 48.2839 56.2023 48.0531L56.2088 40.9486C56.2088 40.476 55.9542 40.0412 55.546 39.8168L47.631 35.473C47.4461 35.3715 47.239 35.3186 47.0286 35.319C46.8182 35.3195 46.6113 35.3733 46.4268 35.4757ZM24.3178 35.4757L16.4487 39.8367C16.2489 39.9478 16.0824 40.1111 15.9665 40.3097C15.8505 40.5082 15.7895 40.7346 15.7898 40.9652V47.9708C15.7898 48.4381 16.0392 48.8682 16.4408 49.0946L24.3198 53.534C24.6971 53.7464 25.1552 53.7491 25.5338 53.54L33.4357 49.1815C33.6358 49.0707 33.8028 48.9075 33.9191 48.709C34.0354 48.5104 34.0967 48.2839 34.0966 48.0531L34.1031 40.9692C34.1038 40.4979 33.8505 40.0631 33.4423 39.8387L25.524 35.4737C25.3389 35.3717 25.1314 35.3185 24.9206 35.3188C24.7099 35.3192 24.5026 35.3731 24.3178 35.4757ZM35.3716 18.4528L27.5019 22.8152C27.3022 22.9263 27.1357 23.0895 27.0198 23.2879C26.9039 23.4863 26.8429 23.7126 26.843 23.943V30.9473C26.843 31.4153 27.0931 31.8467 27.4967 32.0724L35.3953 36.5019C35.7726 36.7136 36.2294 36.715 36.608 36.5065L44.4896 32.1561C44.6896 32.0453 44.8565 31.8822 44.9728 31.6838C45.089 31.4853 45.1504 31.2589 45.1504 31.0283L45.157 23.9264C45.157 23.4538 44.9023 23.019 44.4942 22.7947L36.5765 18.4502C36.3916 18.3487 36.1845 18.2957 35.9741 18.2962C35.7637 18.2967 35.5561 18.3505 35.3716 18.4528Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_41">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Internal(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_47)">
<path
d="M21.3178 53.9842C17.2827 53.6389 14.1993 52.4311 11.9566 50.3171C9.6748 48.1662 8.69578 45.362 9.08219 42.0842C9.52683 38.3121 11.3332 35.6484 14.5001 34.0944C16.941 32.8967 19.3759 32.4411 23.3414 32.4402C26.1589 32.439 28.5197 32.6679 32.1205 33.2896C33.0109 33.4433 33.7817 33.5692 33.8335 33.5693C33.9089 33.5693 33.9686 33.2881 34.1319 32.1638C34.4275 30.1297 34.4805 29.4278 34.4 28.6129C34.3063 27.6644 34.1839 27.2126 33.8218 26.4776C32.8875 24.5811 30.6698 23.4208 27.1084 22.9652C26.0899 22.8349 23.7392 22.8167 22.7189 22.9311C21.0094 23.1229 20.0555 23.3673 17.928 24.1583C16.6834 24.6211 16.6777 24.6226 16.1812 24.5936C15.3277 24.544 14.745 24.1605 14.3714 23.4026C14.2175 23.0904 14.1904 22.9616 14.1904 22.541C14.1904 21.9096 14.3561 21.5744 14.9331 21.0388C16.227 19.8376 18.4043 18.8148 20.6019 18.3756C22.0091 18.0944 22.7018 18.0352 24.5556 18.0381C26.474 18.0431 27.7006 18.1485 29.4054 18.463C32.2408 18.9862 34.3939 19.9084 36.0174 21.2949C36.4991 21.7063 37.2255 22.5161 37.5368 22.9886C37.6319 23.1331 37.7299 23.2512 37.7546 23.2512C37.7792 23.2512 37.9366 23.0777 38.1043 22.8656C40.5627 19.757 44.7031 17.9975 49.5538 18C52.9848 18 56.174 18.8631 58.5512 20.4301C60.4367 21.6731 61.9685 23.5879 62.5873 25.4755C62.9426 26.5593 62.9966 26.9598 62.9999 28.5339C63.0019 29.5134 62.9747 30.1741 62.9196 30.4802C62.3633 33.5741 61.0381 35.7175 58.7379 37.2438C56.8874 38.4716 54.5634 39.1901 51.524 39.4738C49.994 39.6166 47.1955 39.6031 45.3825 39.4441C43.8439 39.3092 41.9546 39.0718 41.1487 38.9121C40.474 38.7785 38.1638 38.3957 38.1448 38.4144C38.1364 38.4229 38.0265 39.1084 37.9006 39.9383C37.6995 41.2634 37.671 41.594 37.6668 42.6521C37.6623 43.7278 37.6777 43.9165 37.8085 44.413C38.2291 46.0094 39.1559 47.0772 40.8374 47.9028C42.4916 48.7149 44.2256 49.0664 46.8458 49.1205C48.0058 49.1447 48.5998 49.1279 49.3256 49.0516C51.0848 48.8665 51.8865 48.6634 54.2239 47.8107C54.8061 47.5983 55.4002 47.405 55.5442 47.3812C55.8527 47.3302 56.4282 47.4356 56.7636 47.6047C57.3691 47.9098 57.8777 48.7235 57.8745 49.3818C57.871 50.1509 57.6096 50.6167 56.8423 51.2224C55.0077 52.6709 52.6653 53.5592 49.8691 53.8669C48.8721 53.9766 46.5956 53.9928 45.4959 53.898C42.3992 53.6311 40.2493 53.1055 38.2845 52.1351C36.7514 51.378 35.5817 50.4387 34.708 49.2631C34.4933 48.9743 34.311 48.7307 34.3028 48.7218C34.2946 48.7129 34.1519 48.8798 33.9857 49.0925C33.251 50.033 32.1276 51.0438 31.0297 51.7522C29.4446 52.7749 27.1692 53.5551 24.8667 53.8654C24.1801 53.9578 21.902 54.0341 21.3178 53.9842ZM25.2715 49.5818C27.1514 49.1684 28.8265 48.2365 30.0427 46.9279C31.152 45.7342 32.0618 43.946 32.5626 41.9753C32.8449 40.8645 33.3141 37.7728 33.2101 37.709C33.169 37.6838 32.921 37.644 32.6591 37.6205C32.3971 37.5967 31.6505 37.5094 30.9998 37.4259C27.135 36.9299 24.764 36.7372 23.4349 36.8112C20.9079 36.9516 19.8777 37.1705 18.3605 37.8896C17.5832 38.2579 17.0467 38.6577 16.6223 39.1847C16.0133 39.9408 15.6457 40.6923 15.3895 41.7048C15.203 42.4415 15.184 44.3707 15.357 44.9923C15.6486 46.0393 15.9946 46.632 16.7728 47.4166C17.8383 48.491 19.2429 49.2078 20.9853 49.5663C21.8762 49.7496 21.8498 49.7478 23.3415 49.7255C24.4239 49.7092 24.8288 49.6791 25.2715 49.5818ZM49.8965 35.0856C51.4686 34.948 52.4354 34.7058 53.6231 34.1521C54.6109 33.6915 55.1851 33.2208 55.7283 32.426C56.5282 31.2559 56.8397 30.1485 56.8317 28.5032C56.826 27.3306 56.7258 26.8428 56.3179 26.0009C55.4064 24.1199 53.2652 22.7726 50.4569 22.3129C49.7561 22.1982 47.8753 22.2186 47.157 22.3485C45.7752 22.5987 44.607 23.0547 43.5769 23.7462C41.6736 25.0237 40.36 26.9494 39.6286 29.5345C39.3413 30.5499 39.2647 30.9397 39.0085 32.69L38.7722 34.3045L39.0887 34.3391C40.0447 34.4443 42.2004 34.7033 43.4523 34.8635C44.2399 34.9642 45.2066 35.0717 45.6004 35.1024C46.7389 35.1911 48.7801 35.1834 49.8965 35.0856Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_47">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Nomad(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_56)">
<path
d="M36.5084 8L12 21.9979V50.0021L36.4916 64L61 50.0021V21.9979L36.5084 8ZM47.4422 38.8104L40.9155 42.541L33.0264 38.2691V47.1958L25.6167 51.8507V33.1979L31.5041 29.6339L39.6624 33.8891V24.7792L47.4422 20.1493V38.8104Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_56">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Plugin(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_53)">
<path
d="M20.2552 18.4399L54.7863 45.2691L53.4913 46.9073C48.0607 53.7778 39.6809 56.8242 32.5108 55.24L31.7107 56.2523C30.5711 57.694 28.6142 58.0775 27.3619 57.1047L24.3262 54.7461L18.1105 62.6097C16.9709 64.0513 15.0139 64.4349 13.7617 63.462L13.0028 62.8723C11.7506 61.8993 11.6584 59.9238 12.798 58.4821L19.0138 50.6185L15.9781 48.2598C14.7259 47.287 14.6337 45.3113 15.7732 43.8697L16.3133 43.1865C12.4663 36.5887 13.2699 27.2771 18.9602 20.0782L20.2552 18.4399ZM58.6629 22.5423C60.3325 23.8395 60.4555 26.4736 58.9361 28.3959L50.5963 39.3693L44.6748 34.6616L52.8646 23.6786C54.3841 21.7564 56.9933 21.2451 58.6629 22.5423ZM42.1563 9.71733C43.826 11.0146 43.9488 13.6487 42.4294 15.5709L33.7195 26.5159L27.9668 21.9735L36.358 10.8537C37.8774 8.93148 40.4867 8.42017 42.1563 9.71733Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_53">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

View file

@ -0,0 +1,19 @@
import { ProviderIconProps } from 'components/icons/providers'
export default function Zookeeper(props: ProviderIconProps) {
return (
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<g clipPath="url(#clip0_2127_50)">
<path
d="M60.9918 38.6897C57.1181 43.6012 52.9346 49.2144 44.5674 49.2144C37.0937 49.2144 34.3093 42.5808 34.1136 37.1919C35.7511 40.6762 38.9526 43.4979 43.9476 43.3673C53.5543 43.0555 60.1396 34.3238 60.1396 26.3718C60.1396 16.8606 53.0895 10 40.8487 10C32.0942 10 21.2479 13.3523 14.1204 18.6537C14.0429 24.1109 17.0644 31.2054 18.149 30.4258C24.3281 25.9551 29.2279 23.0771 33.9804 21.6341C26.9454 29.5284 10.0666 47.8586 8 51.0855C8.23242 54.048 11.8737 62 13.6556 62C14.1979 62 14.6627 61.6882 15.205 61.1424C20.2936 55.389 24.4418 50.2306 28.1314 45.2587C28.6495 52.5457 32.2104 61.4543 42.1658 61.4543C51.0752 61.4543 59.9072 54.9835 63.9358 40.4048C64.4006 38.6117 62.2314 37.2084 60.9918 38.6897ZM50.8428 26.9175C50.8428 31.5172 46.3493 33.7781 42.2432 33.7781C40.0484 33.7781 38.3624 33.1981 37.029 32.4446C39.4824 28.7062 41.911 24.8727 44.5203 20.7691C49.1211 21.5527 50.8428 24.1261 50.8428 26.9175Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_2127_50">
<rect width="72" height="72" fill="currentColor" />
</clipPath>
</defs>
</svg>
)
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,74 @@
import { HTMLAttributes, useMemo } from 'react'
import Consul from 'components/icons/providers/Consul'
import Docker from 'components/icons/providers/Docker'
import ECS from 'components/icons/providers/ECS'
import Etcd from 'components/icons/providers/Etcd'
import File from 'components/icons/providers/File'
import Http from 'components/icons/providers/Http'
import Hub from 'components/icons/providers/Hub'
import Internal from 'components/icons/providers/Internal'
import Kubernetes from 'components/icons/providers/Kubernetes'
import Nomad from 'components/icons/providers/Nomad'
import Plugin from 'components/icons/providers/Plugin'
import Redis from 'components/icons/providers/Redis'
import Zookeeper from 'components/icons/providers/Zookeeper'
export type ProviderIconProps = HTMLAttributes<SVGElement> & {
height?: number | string
width?: number | string
}
export default function ProviderIcon({ name, size = 32 }: { name: string; size?: number }) {
const Icon = useMemo(() => {
if (!name || typeof name !== 'string') return Internal
const nameLowerCase = name.toLowerCase()
if (['consul', 'consul-', 'consulcatalog-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Consul
}
if (['docker', 'swarm'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Docker
}
if (['ecs'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return ECS
}
if (['etcd'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Etcd
}
if (['file'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return File
}
if (['http'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Http
}
if (['hub'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Hub
}
if (['kubernetes'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Kubernetes
}
if (['nomad', 'nomad-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Nomad
}
if (['plugin', 'plugin-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Plugin
}
if (['redis'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Redis
}
if (['zookeeper'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Zookeeper
}
return Internal
}, [name])
return (
<Icon
height={size}
width={size}
style={{ backgroundColor: 'var(--colors-primary)', borderRadius: '50%', color: 'var(--colors-01dp)' }}
/>
)
}

View file

@ -0,0 +1,53 @@
import AdditionalFeatures from './AdditionalFeatures'
import { MiddlewareProps } from 'hooks/use-resource-detail'
import { renderWithProviders } from 'utils/test'
describe('<AdditionalFeatures />', () => {
it('should render the middleware info', () => {
renderWithProviders(<AdditionalFeatures uid="test-key" />)
})
it('should render the middleware info with number', () => {
const middlewares: MiddlewareProps[] = [
{
retry: {
attempts: 2,
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('Retry: Attempts=2')
})
it('should render the middleware info with string', () => {
const middlewares: MiddlewareProps[] = [
{
circuitBreaker: {
expression: 'expression',
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('CircuitBreaker: Expression="expression"')
})
it('should render the middleware info with string', () => {
const middlewares: MiddlewareProps[] = [
{
rateLimit: {
burst: 100,
average: 100,
},
},
]
const { container } = renderWithProviders(<AdditionalFeatures uid="test-key" middlewares={middlewares} />)
expect(container.innerHTML).toContain('RateLimit: Burst=100, Average=100')
})
})

View file

@ -0,0 +1,73 @@
import { Badge, Box, Text } from '@traefiklabs/faency'
import Tooltip from 'components/Tooltip'
import { MiddlewareProps, ValuesMapType } from 'hooks/use-resource-detail'
function capitalize(word: string): string {
return word.charAt(0).toUpperCase() + word.slice(1)
}
function quote(value: string | number): string | number {
if (typeof value === 'string') {
return `"${value}"`
}
return value
}
function quoteArray(values: (string | number)[]): (string | number)[] {
return values.map(quote)
}
const renderFeatureValues = (valuesMap: ValuesMapType): string => {
return Object.entries(valuesMap)
.map(([name, value]) => {
const capitalizedName = capitalize(name)
if (typeof value === 'string') {
return [capitalizedName, `"${value}"`].join('=')
}
if (value instanceof Array) {
return [capitalizedName, quoteArray(value).join(', ')].join('=')
}
if (typeof value === 'object') {
return [capitalizedName, `{${renderFeatureValues(value)}}`].join('=')
}
return [capitalizedName, value].join('=')
})
.join(', ')
}
const FeatureMiddleware = ({ middleware }: { middleware: MiddlewareProps }) => {
const [name, value] = Object.entries(middleware)[0]
const content = `${capitalize(name)}: ${renderFeatureValues(value)}`
return (
<Tooltip label={content} action="copy">
<Badge variant="blue" css={{ mr: '$2', mt: '$2' }}>
{content}
</Badge>
</Tooltip>
)
}
type AdditionalFeaturesProps = {
middlewares?: MiddlewareProps[]
uid: string
}
const AdditionalFeatures = ({ middlewares, uid }: AdditionalFeaturesProps) => {
return middlewares?.length ? (
<Box css={{ mt: '-$2' }}>
{middlewares.map((m, idx) => (
<FeatureMiddleware key={`${uid}-${idx}`} middleware={m} />
))}
</Box>
) : (
<Text css={{ fontStyle: 'italic', color: 'hsl(0, 0%, 56%)' }}>No additional features</Text>
)
}
export default AdditionalFeatures

View file

@ -0,0 +1,352 @@
import { Badge, Box, Card, Flex, H2, styled, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { FiArrowRight, FiToggleLeft, FiToggleRight } from 'react-icons/fi'
import { useNavigate } from 'react-router-dom'
import { StatusWrapper } from './ResourceStatus'
import { colorByStatus } from './Status'
import Tooltip from 'components/Tooltip'
const CustomHeading = styled(H2, {
display: 'flex',
alignItems: 'center',
})
type SectionHeaderType = {
icon?: ReactNode
title?: string | undefined
}
export const SectionHeader = ({ icon, title }: SectionHeaderType) => {
if (!title) {
return (
<CustomHeading css={{ mb: '$6' }}>
<Box css={{ width: 5, height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1 }} />
<Box css={{ width: '50%', maxWidth: '300px', height: 4, bg: 'hsl(220, 6%, 90%)', borderRadius: 1, ml: '$2' }} />
</CustomHeading>
)
}
return (
<CustomHeading css={{ mb: '$5' }}>
{icon ? icon : null}
<Text size={6} css={{ ml: '$2' }}>
{title}
</Text>
</CustomHeading>
)
}
export const ItemTitle = styled(Text, {
marginBottom: '$3',
color: 'hsl(0, 0%, 56%)',
letterSpacing: '3px',
fontSize: '12px',
fontWeight: 600,
textAlign: 'left',
textTransform: 'uppercase',
wordBreak: 'break-word',
})
const SpacedCard = styled(Card, {
'& + &': {
marginTop: '16px',
},
})
const CardDescription = styled(Text, {
textAlign: 'left',
fontWeight: '700',
fontSize: '16px',
lineHeight: '16px',
wordBreak: 'break-word',
})
const CardListColumnWrapper = styled(Flex, {
display: 'flex',
})
const CardListColumn = styled(Flex, {
minWidth: 160,
maxWidth: '66%',
maxHeight: '416px',
overflowY: 'auto',
p: '$1',
})
const ItemBlockContainer = styled(Flex, {
maxWidth: '100%',
flexWrap: 'wrap !important',
rowGap: '$2',
// This forces the Tooltips to respect max-width, since we can't define
// it directly on the component, and the Chips are automatically covered.
span: {
maxWidth: '100%',
},
})
const FlexLink = styled('a', {
display: 'flex',
flexFlow: 'column',
textDecoration: 'none',
})
type CardType = {
title: string
description?: string
focus?: boolean
link?: string
}
type SectionType = SectionHeaderType & {
cards?: CardType[] | undefined
isLast?: boolean
bigDescription?: boolean
}
const CardSkeleton = ({ bigDescription }: { bigDescription?: boolean }) => {
return (
<SpacedCard css={{ p: '$3' }}>
<ItemTitle>
<Box css={{ height: '12px', bg: '$slate5', borderRadius: 1, mb: '$3', mr: '60%' }} />
</ItemTitle>
<CardDescription>
<Box
css={{
height: bigDescription ? '22px' : '14px',
mr: '20%',
bg: '$slate5',
borderRadius: 1,
}}
/>
</CardDescription>
</SpacedCard>
)
}
export const CardListSection = ({ icon, title, cards, isLast, bigDescription }: SectionType) => {
const navigate = useNavigate()
return (
<Flex css={{ flexDirection: 'column', flexGrow: 1 }}>
<SectionHeader icon={icon} title={title} />
<CardListColumnWrapper>
<CardListColumn>
<Flex css={{ flexDirection: 'column', flexGrow: 1, marginRight: '$3' }}>
{!cards && <CardSkeleton bigDescription={bigDescription} />}
{cards
?.filter((c) => !!c.description)
.map((card) => (
<SpacedCard key={card.description} css={{ border: card.focus ? `2px solid $primary` : '', p: '$3' }}>
<FlexLink
data-testid={card.link}
onClick={(): false | void => !!card.link && navigate(card.link)}
css={{ cursor: card.link ? 'pointer' : 'inherit' }}
>
<ItemTitle>{card.title}</ItemTitle>
<CardDescription>{card.description}</CardDescription>
</FlexLink>
</SpacedCard>
))}
<Box css={{ height: '16px' }}>&nbsp;</Box>
</Flex>
</CardListColumn>
{!isLast && (
<Flex css={{ mt: '$5', mx: 'auto' }}>
<FiArrowRight color="hsl(0, 0%, 76%)" size={24} />
</Flex>
)}
</CardListColumnWrapper>
</Flex>
)
}
const FlexCard = styled(Card, {
display: 'flex',
flexFlow: 'column',
flexGrow: '1',
overflowY: 'auto',
height: '600px',
})
const NarrowFlexCard = styled(FlexCard, {
height: '400px',
})
const ItemTitleSkeleton = styled(Box, {
height: '16px',
backgroundColor: '$slate5',
borderRadius: '3px',
})
const ItemDescriptionSkeleton = styled(Box, {
height: '16px',
backgroundColor: '$slate5',
borderRadius: '3px',
})
type DetailSectionSkeletonType = {
narrow?: boolean
}
export const DetailSectionSkeleton = ({ narrow }: DetailSectionSkeletonType) => {
const Card = narrow ? NarrowFlexCard : FlexCard
return (
<Flex css={{ flexDirection: 'column' }}>
<SectionHeader />
<Card css={{ p: '$5' }}>
<LayoutTwoCols css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '40%' }} />
<ItemTitleSkeleton css={{ width: '40%' }} />
</LayoutTwoCols>
<LayoutTwoCols css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '90%' }} />
<ItemDescriptionSkeleton css={{ width: '90%' }} />
</LayoutTwoCols>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '50%' }} />
</Flex>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '70%' }} />
</Flex>
<Flex css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '30%' }} />
</Flex>
<Flex css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '50%' }} />
</Flex>
<LayoutTwoCols css={{ mb: '$2' }}>
<ItemTitleSkeleton css={{ width: '40%' }} />
<ItemTitleSkeleton css={{ width: '40%' }} />
</LayoutTwoCols>
<LayoutTwoCols css={{ mb: '$5' }}>
<ItemDescriptionSkeleton css={{ width: '90%' }} />
<ItemDescriptionSkeleton css={{ width: '90%' }} />
</LayoutTwoCols>
</Card>
</Flex>
)
}
type DetailSectionType = SectionHeaderType & {
children?: ReactNode
noPadding?: boolean
narrow?: boolean
}
export const DetailSection = ({ icon, title, children, narrow, noPadding }: DetailSectionType) => {
const Card = narrow ? NarrowFlexCard : FlexCard
return (
<Flex css={{ flexDirection: 'column' }}>
<SectionHeader icon={icon} title={title} />
<Card css={{ padding: noPadding ? 0 : '$5' }}>{children}</Card>
</Flex>
)
}
const FlexLimited = styled(Flex, {
maxWidth: '100%',
margin: '0 -8px -8px 0',
span: {
maxWidth: '100%',
},
})
type ChipsType = {
items: string[]
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
alignment?: 'center' | 'left'
}
export const Chips = ({ items, variant, alignment = 'left' }: ChipsType) => (
<FlexLimited wrap="wrap">
{items.map((item, index) => (
<Tooltip key={index} label={item} action="copy">
<Badge variant={variant} css={{ textAlign: alignment, mr: '$2', mb: '$2' }}>
{item}
</Badge>
</Tooltip>
))}
</FlexLimited>
)
type ChipPropsListType = {
data: {
[key: string]: string
}
variant?: 'gray' | 'red' | 'blue' | 'green' | 'neon' | 'orange' | 'purple'
}
export const ChipPropsList = ({ data, variant }: ChipPropsListType) => (
<Flex css={{ flexWrap: 'wrap' }}>
{Object.entries(data).map((entry: [string, string]) => (
<Badge key={entry[0]} variant={variant} css={{ textAlign: 'left', mr: '$2', mb: '$2' }}>
{entry[1]}
</Badge>
))}
</Flex>
)
type ItemBlockType = {
title: string
children?: ReactNode
}
export const ItemBlock = ({ title, children }: ItemBlockType) => (
<Flex css={{ flexDirection: 'column', mb: '$5' }}>
<ItemTitle>{title}</ItemTitle>
<ItemBlockContainer css={{ alignItems: 'center' }}>{children}</ItemBlockContainer>
</Flex>
)
const LayoutCols = styled(Box, {
display: 'grid',
gridGap: '16px',
})
export const LayoutTwoCols = styled(LayoutCols, {
gridTemplateColumns: 'repeat(2, minmax(50%, 1fr))',
})
export const LayoutThreeCols = styled(LayoutCols, {
gridTemplateColumns: 'repeat(3, minmax(30%, 1fr))',
})
export const BooleanState = ({ enabled }: { enabled: boolean }) => (
<Flex align="center" gap={2}>
<StatusWrapper
css={{
alignItems: 'center',
justifyContent: 'center',
backgroundColor: enabled ? colorByStatus.enabled : colorByStatus.disabled,
}}
data-testid={`enabled-${enabled}`}
>
{enabled ? <FiToggleRight color="#fff" size={20} /> : <FiToggleLeft color="#fff" size={20} />}
</StatusWrapper>
<Text css={{ color: enabled ? colorByStatus.enabled : colorByStatus.disabled, fontWeight: 600 }}>
{enabled ? 'True' : 'False'}
</Text>
</Flex>
)
export const ProviderName = styled(Text, {
textTransform: 'capitalize',
overflowWrap: 'break-word',
})
export const EmptyPlaceholder = styled(Text, {
color: 'hsl(0, 0%, 76%)',
fontSize: '20px',
fontWeight: '700',
lineHeight: '1.2',
})

View file

@ -0,0 +1,45 @@
import { Box, Card, Flex, Grid, Skeleton as FaencySkeleton, Text } from '@traefiklabs/faency'
import ResourceCard from 'components/resources/ResourceCard'
const FeatureCard = ({ feature }) => {
const value = feature.value
return (
<ResourceCard title={feature.name}>
<Box
css={{
px: '$3',
borderRadius: '$2',
py: '$2',
backgroundColor: !value ? '$red6' : typeof value === 'boolean' ? '$green6' : '$gray6',
}}
>
<Text
css={{
fontSize: '$10',
fontWeight: 500,
color: !value ? '$red10' : typeof value === 'boolean' ? '$green10' : '$gray10',
textAlign: 'center',
}}
>
{!value ? 'OFF' : typeof value === 'boolean' ? 'ON' : value}
</Text>
</Box>
</ResourceCard>
)
}
export const FeatureCardSkeleton = () => {
return (
<Grid gap={6} css={{ gridTemplateColumns: 'repeat(auto-fill, minmax(215px, 1fr))' }}>
<Card css={{ minHeight: '125px' }}>
<Flex justify="space-between" align="center" direction="column" css={{ height: '100%', p: '$2' }}>
<FaencySkeleton css={{ width: 150, height: 13, mb: '$3' }} />
<FaencySkeleton css={{ width: 80, height: 40 }} />
</Flex>
</Card>
</Grid>
)
}
export default FeatureCard

View file

@ -0,0 +1,45 @@
import { AriaTable, AriaTbody, AriaTd, AriaTr, Flex, Text } from '@traefiklabs/faency'
import { useMemo } from 'react'
import Status, { StatusType } from './Status'
import Tooltip from 'components/Tooltip'
type GenericTableProps = {
items: (number | string)[]
status?: StatusType
}
export default function GenericTable({ items, status }: GenericTableProps) {
const border = useMemo(() => `1px solid $${status === 'error' ? 'textRed' : 'tableRowBorder'}`, [status])
return (
<AriaTable css={{ wordBreak: 'break-word', boxShadow: 'none', border }}>
<AriaTbody>
{items.map((item, index) => (
<AriaTr key={index}>
<AriaTd css={{ p: '$2' }}>
<Tooltip label={item.toString()} action="copy">
<Flex align="start" gap={2} css={{ width: 'fit-content' }}>
{status ? (
<Status status="error" css={{ p: '4px', marginRight: 0 }} size={16} />
) : (
<Text css={{ fontFamily: 'monospace', mt: '1px', userSelect: 'none' }} variant="subtle">
{index}
</Text>
)}
<Text
css={{ fontFamily: status === 'error' ? 'monospace' : undefined }}
variant={status === 'error' ? 'red' : undefined}
>
{item}
</Text>
</Flex>
</Tooltip>
</AriaTd>
</AriaTr>
))}
</AriaTbody>
</AriaTable>
)
}

View file

@ -0,0 +1,45 @@
import { AriaTable, AriaTbody, AriaTd, AriaTr, Badge, Flex, Text } from '@traefiklabs/faency'
import Tooltip from 'components/Tooltip'
export type IpStrategy = {
depth: number
excludedIPs: string[]
}
export default function IpStrategyTable({ ipStrategy }: { ipStrategy: IpStrategy }) {
return (
<AriaTable css={{ wordBreak: 'break-word', boxShadow: 'none', border: '1px solid $tableRowBorder' }}>
<AriaTbody>
{ipStrategy.depth ? (
<AriaTr>
<AriaTd css={{ width: '104px', p: '$2' }}>
<Text variant="subtle">Depth</Text>
</AriaTd>
<AriaTd css={{ p: '$2' }}>
<Tooltip label={ipStrategy.depth.toString()} action="copy">
<Text>{ipStrategy.depth}</Text>
</Tooltip>
</AriaTd>
</AriaTr>
) : null}
{ipStrategy.excludedIPs ? (
<AriaTr>
<AriaTd css={{ width: '104px', p: '$2', verticalAlign: 'baseline' }}>
<Text variant="subtle">Excluded IPs</Text>
</AriaTd>
<AriaTd css={{ p: '$2' }}>
<Flex gap={1} css={{ flexWrap: 'wrap' }}>
{ipStrategy.excludedIPs.map((ip, index) => (
<Tooltip key={index} label={ip} action="copy">
<Badge> {ip}</Badge>
</Tooltip>
))}
</Flex>
</AriaTd>
</AriaTr>
) : null}
</AriaTbody>
</AriaTable>
)
}

View file

@ -0,0 +1,113 @@
import { Box, Flex, H3, styled, Text } from '@traefiklabs/faency'
import { FiLayers } from 'react-icons/fi'
import { DetailSection, EmptyPlaceholder, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
import GenericTable from './GenericTable'
import { RenderUnknownProp } from './RenderUnknownProp'
import { ResourceStatus } from './ResourceStatus'
import { EmptyIcon } from 'components/icons/EmptyIcon'
import ProviderIcon from 'components/icons/providers'
import { Middleware, RouterDetailType } from 'hooks/use-resource-detail'
import { parseMiddlewareType } from 'libs/parsers'
const Separator = styled('hr', {
border: 'none',
background: '$tableRowBorder',
margin: '0 0 24px',
height: '1px',
minHeight: '1px',
})
const filterMiddlewareProps = (middleware: Middleware): string[] => {
const filteredProps = [] as string[]
const propsToRemove = ['name', 'plugin', 'status', 'type', 'provider', 'error', 'usedBy', 'routers']
Object.keys(middleware).map((propName) => {
if (!propsToRemove.includes(propName)) {
filteredProps.push(propName)
}
})
return filteredProps
}
type RenderMiddlewareProps = {
middleware: Middleware
withHeader?: boolean
}
export const RenderMiddleware = ({ middleware, withHeader }: RenderMiddlewareProps) => (
<Flex key={middleware.name} css={{ flexDirection: 'column' }}>
{withHeader && <H3 css={{ mb: '$7', overflowWrap: 'break-word' }}>{middleware.name}</H3>}
<LayoutTwoCols>
{(middleware.type || middleware.plugin) && (
<ItemBlock title="Type">
<Text css={{ lineHeight: '32px', overflowWrap: 'break-word' }}>{parseMiddlewareType(middleware)}</Text>
</ItemBlock>
)}
{middleware.provider && (
<ItemBlock title="Provider">
<ProviderIcon name={middleware.provider} />
<ProviderName css={{ ml: '$2' }}>{middleware.provider}</ProviderName>
</ItemBlock>
)}
</LayoutTwoCols>
{middleware.status && (
<ItemBlock title="Status">
<ResourceStatus status={middleware.status} withLabel />
</ItemBlock>
)}
{middleware.error && (
<ItemBlock title="Errors">
<GenericTable items={middleware.error} status="error" />
</ItemBlock>
)}
{middleware.plugin &&
Object.keys(middleware.plugin).map((pluginName) => (
<RenderUnknownProp key={pluginName} name={pluginName} prop={middleware.plugin?.[pluginName]} />
))}
{filterMiddlewareProps(middleware).map((propName) => (
<RenderUnknownProp
key={propName}
name={propName}
prop={middleware[propName]}
removeTitlePrefix={middleware.type}
/>
))}
</Flex>
)
const MiddlewarePanel = ({ data }: { data: RouterDetailType }) => (
<DetailSection icon={<FiLayers size={20} />} title="Middlewares">
{data.middlewares ? (
data.middlewares.map((middleware, index) => (
<Box key={middleware.name}>
<RenderMiddleware middleware={middleware} withHeader />
{data.middlewares && index < data.middlewares.length - 1 && <Separator />}
</Box>
))
) : (
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
<Box
css={{
width: 88,
svg: {
width: '100%',
height: '100%',
},
}}
>
<EmptyIcon />
</Box>
<EmptyPlaceholder css={{ mt: '$3' }}>
There are no
<br />
Middlewares configured
</EmptyPlaceholder>
</Flex>
)}
</DetailSection>
)
export default MiddlewarePanel

View file

@ -0,0 +1,74 @@
import { useMemo } from 'react'
import Consul from 'components/icons/providers/Consul'
import Docker from 'components/icons/providers/Docker'
import ECS from 'components/icons/providers/ECS'
import Etcd from 'components/icons/providers/Etcd'
import File from 'components/icons/providers/File'
import Http from 'components/icons/providers/Http'
import Hub from 'components/icons/providers/Hub'
import Internal from 'components/icons/providers/Internal'
import Kubernetes from 'components/icons/providers/Kubernetes'
import Nomad from 'components/icons/providers/Nomad'
import Plugin from 'components/icons/providers/Plugin'
import Redis from 'components/icons/providers/Redis'
import Zookeeper from 'components/icons/providers/Zookeeper'
type ProviderIconProps = {
name: string
size?: number
}
export const ProviderIcon = ({ name, size = 32 }: ProviderIconProps) => {
const Icon = useMemo(() => {
if (!name || typeof name !== 'string') return Internal
const nameLowerCase = name.toLowerCase()
if (['consul', 'consul-', 'consulcatalog-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Consul
}
if (['docker', 'swarm'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Docker
}
if (['ecs'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return ECS
}
if (['etcd'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Etcd
}
if (['file'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return File
}
if (['http'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Http
}
if (['hub'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Hub
}
if (['kubernetes'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Kubernetes
}
if (['nomad', 'nomad-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Nomad
}
if (['plugin', 'plugin-'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Plugin
}
if (['redis'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Redis
}
if (['zookeeper'].some((prefix) => nameLowerCase.startsWith(prefix))) {
return Zookeeper
}
return Internal
}, [name])
return (
<Icon
height={size}
width={size}
style={{ backgroundColor: 'var(--colors-primary)', borderRadius: '50%', color: 'var(--colors-01dp)' }}
/>
)
}

View file

@ -0,0 +1,162 @@
import { RenderUnknownProp } from './RenderUnknownProp'
import { renderWithProviders } from 'utils/test'
describe('<RenderUnknownProp />', () => {
it('renders a string correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringPropName" prop="string prop value" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('string prop value')
})
it('renders a number correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="NumberPropName" prop={123123} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('NumberPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('123123')
})
it('renders false correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={false} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders boolean true correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={true} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders boolean false correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="BooleanPropName" prop={false} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('BooleanPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders string `true` correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringBoolPropName" prop="true" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringBoolPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders string `false` correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="StringBoolPropName" prop="false" />)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringBoolPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-false')
expect(container.querySelector('div > div')?.innerHTML).toContain('False')
})
it('renders empty object correctly', () => {
const { container } = renderWithProviders(<RenderUnknownProp name="EmptyObjectPropName" prop={{}} />)
expect(container.querySelector('div > span')?.innerHTML).toContain('EmptyObjectPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('enabled-true')
expect(container.querySelector('div > div')?.innerHTML).toContain('True')
})
it('renders list of strings correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp name="StringListPropName" prop={['string1', 'string2', 'string3']} />,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('StringListPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('string1')
expect(container.querySelector('div > div')?.innerHTML).toContain('string2')
expect(container.querySelector('div > div')?.innerHTML).toContain('string3')
})
it('renders list of objects correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="ObjectListPropName"
prop={[{ array: [] }, { otherObject: {} }, { word: 'test' }, { number: 123 }, { boolean: false, or: true }]}
/>,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('ObjectListPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"array":[]}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"otherObject":{}}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"word":"test"}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"number":123}')
expect(container.querySelector('div > div')?.innerHTML).toContain('{"boolean":false,"or":true}')
})
it('renders recursive objects correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
prop={{
parentProperty: {
childProperty: {
valueProperty1: 'test',
valueProperty2: ['item1', 'item2', 'item3'],
},
},
}}
/>,
)
expect(container.querySelector('div:first-child > span')?.innerHTML).toContain(
'RecursiveObjectPropName &gt; parent Property &gt; child Property &gt; value Property1',
)
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
'RecursiveObjectPropName &gt; parent Property &gt; child Property &gt; value Property2',
)
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item1')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item2')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item3')
})
it('renders recursive objects removing title prefix correctly', () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
removeTitlePrefix="RecursiveObjectPropName"
prop={{
parentProperty: {
childProperty: {
valueProperty1: 'test',
valueProperty2: ['item1', 'item2', 'item3'],
},
},
}}
/>,
)
expect(container.querySelector('div:first-child > span')?.innerHTML).toContain(
'parent Property &gt; child Property &gt; value Property1',
)
expect(container.querySelector('div:first-child > div')?.innerHTML).toContain('test')
expect(container.querySelector('div:first-child ~ div > span')?.innerHTML).toContain(
'parent Property &gt; child Property &gt; value Property2',
)
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item1')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item2')
expect(container.querySelector('div:first-child ~ div > div')?.innerHTML).toContain('item3')
})
it(`renders should not remove prefix if there's no child`, () => {
const { container } = renderWithProviders(
<RenderUnknownProp
name="RecursiveObjectPropName"
removeTitlePrefix="RecursiveObjectPropName"
prop="DummyValue"
/>,
)
expect(container.querySelector('div > span')?.innerHTML).toContain('RecursiveObjectPropName')
expect(container.querySelector('div > div')?.innerHTML).toContain('DummyValue')
})
})

View file

@ -0,0 +1,76 @@
import { Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { BooleanState, ItemBlock } from './DetailSections'
import GenericTable from './GenericTable'
import IpStrategyTable, { IpStrategy } from './IpStrategyTable'
import Tooltip from 'components/Tooltip'
type RenderUnknownPropProps = {
name: string
prop?: unknown
removeTitlePrefix?: string
}
export const RenderUnknownProp = ({ name, prop, removeTitlePrefix }: RenderUnknownPropProps) => {
const wrap = (children: ReactNode, altName?: string, key?: string) => (
<ItemBlock key={key} title={altName || name}>
{children}
</ItemBlock>
)
try {
if (typeof prop !== 'undefined') {
if (typeof prop === 'boolean') {
return wrap(<BooleanState enabled={prop} />)
}
if (typeof prop === 'string' && ['true', 'false'].includes((prop as string).toLowerCase())) {
return wrap(<BooleanState enabled={prop === 'true'} />)
}
if (['string', 'number'].includes(typeof prop)) {
return wrap(
<Tooltip label={prop as string} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{prop as string}</Text>
</Tooltip>,
)
}
if (JSON.stringify(prop) === '{}') {
return wrap(<BooleanState enabled />)
}
if (prop instanceof Array) {
return wrap(
<GenericTable items={prop.map((p) => (['number', 'string'].includes(typeof p) ? p : JSON.stringify(p)))} />,
)
}
if (prop?.constructor === Object) {
return (
<>
{Object.entries(prop).map(([childName, childProp]) => {
const spacedChildName = childName.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
let title = `${name} > ${spacedChildName}`
if (removeTitlePrefix) {
title = title.replace(new RegExp(`^${removeTitlePrefix} > `, 'i'), '')
}
switch (childName) {
case 'ipStrategy':
return wrap(<IpStrategyTable ipStrategy={childProp as IpStrategy} />, title, title)
default:
return <RenderUnknownProp key={title} name={title} prop={childProp} />
}
})}
</>
)
}
}
} catch (error) {
console.log('Unable to render plugin property:', { name, prop }, { error })
}
return null
}

View file

@ -0,0 +1,26 @@
import { Card, CSS, Flex, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
type ResourceCardProps = {
children: ReactNode
css?: CSS
title?: string
titleCSS?: CSS
}
const ResourceCard = ({ children, css, title, titleCSS = {} }: ResourceCardProps) => {
return (
<Card css={css}>
<Flex direction="column" align="center" justify="center" gap={3} css={{ height: '100%', p: '$2' }}>
{title && (
<Text variant="subtle" css={{ letterSpacing: 3, fontSize: '$2', wordBreak: 'break-all', ...titleCSS }}>
{title.toUpperCase()}
</Text>
)}
{children}
</Flex>
</Card>
)
}
export default ResourceCard

View file

@ -0,0 +1,71 @@
import { Flex, styled, Text } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { colorByStatus, iconByStatus, StatusType } from 'components/resources/Status'
export const StatusWrapper = styled(Flex, {
height: '32px',
width: '32px',
padding: 0,
borderRadius: '4px',
})
type Props = {
status: StatusType
label?: string
withLabel?: boolean
}
type Value = { color: string; icon: ReactNode; label: string }
export const ResourceStatus = ({ status, withLabel = false }: Props) => {
const valuesByStatus: { [key in StatusType]: Value } = {
info: {
color: colorByStatus.info,
icon: iconByStatus.info,
label: 'Info',
},
success: {
color: colorByStatus.success,
icon: iconByStatus.success,
label: 'Success',
},
warning: {
color: colorByStatus.warning,
icon: iconByStatus.warning,
label: 'Warning',
},
error: {
color: colorByStatus.error,
icon: iconByStatus.error,
label: 'Error',
},
enabled: {
color: colorByStatus.enabled,
icon: iconByStatus.enabled,
label: 'Success',
},
disabled: {
color: colorByStatus.disabled,
icon: iconByStatus.disabled,
label: 'Error',
},
}
const values = valuesByStatus[status]
if (!values) {
return null
}
return (
<Flex css={{ alignItems: 'center' }} data-testid={status}>
<StatusWrapper css={{ alignItems: 'center', justifyContent: 'center', backgroundColor: values.color }}>
{values.icon}
</StatusWrapper>
{withLabel && values.label && (
<Text css={{ ml: '$2', color: values.color, fontWeight: 600 }}>{values.label}</Text>
)}
</Flex>
)
}

View file

@ -0,0 +1,76 @@
import { Badge, Text } from '@traefiklabs/faency'
import { FiInfo } from 'react-icons/fi'
import { DetailSection, ItemBlock, LayoutTwoCols, ProviderName } from './DetailSections'
import GenericTable from './GenericTable'
import { ResourceStatus } from './ResourceStatus'
import ProviderIcon from 'components/icons/providers'
import Tooltip from 'components/Tooltip'
import { ResourceDetailDataType } from 'hooks/use-resource-detail'
type Props = {
data: ResourceDetailDataType
}
const RouterPanel = ({ data }: Props) => (
<DetailSection icon={<FiInfo size={20} />} title="Router Details">
<LayoutTwoCols>
{data.status && (
<ItemBlock title="Status">
<ResourceStatus status={data.status} withLabel />
</ItemBlock>
)}
{data.provider && (
<ItemBlock title="Provider">
<ProviderIcon name={data.provider} />
<ProviderName css={{ ml: '$2' }}>{data.provider}</ProviderName>
</ItemBlock>
)}
{data.priority && (
<ItemBlock title="Priority">
<Tooltip label={data.priority.toString()} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.priority.toString()}</Text>
</Tooltip>
</ItemBlock>
)}
</LayoutTwoCols>
{data.rule ? (
<ItemBlock title="Rule">
<Tooltip label={data.rule} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.rule}</Text>
</Tooltip>
</ItemBlock>
) : null}
{data.name && (
<ItemBlock title="Name">
<Tooltip label={data.name} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.name}</Text>
</Tooltip>
</ItemBlock>
)}
{!!data.using && data.using && data.using.length > 0 && (
<ItemBlock title="Entrypoints">
{data.using.map((ep) => (
<Tooltip key={ep} label={ep} action="copy">
<Badge css={{ mr: '$2' }}>{ep}</Badge>
</Tooltip>
))}
</ItemBlock>
)}
{data.service && (
<ItemBlock title="Service">
<Tooltip label={data.service} action="copy">
<Text css={{ overflowWrap: 'break-word' }}>{data.service}</Text>
</Tooltip>
</ItemBlock>
)}
{data.error && (
<ItemBlock title="Errors">
<GenericTable items={data.error} status="error" />
</ItemBlock>
)}
</DetailSection>
)
export default RouterPanel

View file

@ -0,0 +1,68 @@
import { Box, CSS } from '@traefiklabs/faency'
import { ReactNode } from 'react'
import { FiAlertCircle, FiAlertTriangle, FiCheckCircle } from 'react-icons/fi'
export type StatusType = 'info' | 'success' | 'warning' | 'error' | 'enabled' | 'disabled'
export const iconByStatus: { [key in StatusType]: ReactNode } = {
info: <FiAlertCircle color="white" size={20} />,
success: <FiCheckCircle color="white" size={20} />,
warning: <FiAlertCircle color="white" size={20} />,
error: <FiAlertTriangle color="white" size={20} />,
enabled: <FiCheckCircle color="white" size={20} />,
disabled: <FiAlertTriangle color="white" size={20} />,
}
// Please notice: dark and light colors have the same values.
export const colorByStatus: { [key in StatusType]: string } = {
info: 'hsl(220, 67%, 51%)',
success: '#30A46C',
warning: 'hsl(24 94.0% 50.0%)',
error: 'hsl(347, 100%, 60.0%)',
enabled: '#30A46C',
disabled: 'hsl(347, 100%, 60.0%)',
}
type StatusProps = {
css?: CSS
size?: number
status: StatusType
}
export default function Status({ css = {}, size = 20, status }: StatusProps) {
const Icon = ({ size }: { size: number }) => {
switch (status) {
case 'info':
return <FiAlertCircle color="white" size={size} />
case 'success':
return <FiCheckCircle color="white" size={size} />
case 'warning':
return <FiAlertCircle color="white" size={size} />
case 'error':
return <FiAlertTriangle color="white" size={size} />
case 'enabled':
return <FiCheckCircle color="white" size={size} />
case 'disabled':
return <FiAlertTriangle color="white" size={size} />
default:
return null
}
}
return (
<Box
css={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '4px',
backgroundColor: colorByStatus[status],
marginRight: '10px',
padding: '6px',
...css,
}}
>
<Icon size={size} />
</Box>
)
}

View file

@ -0,0 +1,77 @@
import { Badge, Box, Flex, Text } from '@traefiklabs/faency'
import { FiShield } from 'react-icons/fi'
import { BooleanState, DetailSection, EmptyPlaceholder, ItemBlock } from './DetailSections'
import { EmptyIcon } from 'components/icons/EmptyIcon'
import { RouterDetailType } from 'hooks/use-resource-detail'
type Props = {
data: RouterDetailType
}
const TlsPanel = ({ data }: Props) => (
<DetailSection icon={<FiShield size={20} />} title="TLS">
{data.tls ? (
<Flex css={{ flexDirection: 'column' }}>
<ItemBlock title="TLS">
<BooleanState enabled />
</ItemBlock>
{data.tls.options && (
<ItemBlock title="Options">
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.options}</Text>
</ItemBlock>
)}
<ItemBlock title="PassThrough">
<BooleanState enabled={!!data.tls.passthrough} />
</ItemBlock>
{data.tls.certResolver && (
<ItemBlock title="Certificate Resolver">
<Text css={{ overflowWrap: 'break-word' }}>{data.tls.certResolver}</Text>
</ItemBlock>
)}
{data.tls.domains && (
<ItemBlock title="Domains">
<Flex css={{ flexDirection: 'column' }}>
{data.tls.domains?.map((domain) => (
<Flex key={domain.main} css={{ flexWrap: 'wrap' }}>
<a href={`//${domain.main}`}>
<Badge variant="blue" css={{ mr: '$2', mb: '$2', color: '$primary', borderColor: '$primary' }}>
{domain.main}
</Badge>
</a>
{domain.sans?.map((sub) => (
<a key={sub} href={`//${sub}`}>
<Badge css={{ mr: '$2', mb: '$2' }}>{sub}</Badge>
</a>
))}
</Flex>
))}
</Flex>
</ItemBlock>
)}
</Flex>
) : (
<Flex direction="column" align="center" justify="center" css={{ flexGrow: 1, textAlign: 'center' }}>
<Box
css={{
width: 88,
svg: {
width: '100%',
height: '100%',
},
}}
>
<EmptyIcon />
</Box>
<EmptyPlaceholder css={{ mt: '$3' }}>
There is no
<br />
TLS configured
</EmptyPlaceholder>
</Flex>
)}
</DetailSection>
)
export default TlsPanel

View file

@ -0,0 +1,53 @@
import TraefikResourceStatsCard from './TraefikResourceStatsCard'
import { renderWithProviders } from 'utils/test'
describe('<TraefikResourceStatsCard />', () => {
it('should render the component and show the expected data (success count is zero)', () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={2} total={5} warnings={3} linkTo="" />,
)
expect(getByTestId('success-pc').innerHTML).toContain('0%')
expect(getByTestId('success-count').innerHTML).toContain('0')
expect(getByTestId('warnings-pc').innerHTML).toContain('60%')
expect(getByTestId('warnings-count').innerHTML).toContain('3')
expect(getByTestId('errors-pc').innerHTML).toContain('40%')
expect(getByTestId('errors-count').innerHTML).toContain('2')
})
it('should render the component and show the expected data (success count is not zero)', async () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={2} total={7} warnings={4} linkTo="" />,
)
expect(getByTestId('success-pc').innerHTML).toContain('14%')
expect(getByTestId('success-count').innerHTML).toContain('1')
expect(getByTestId('warnings-pc').innerHTML).toContain('57%')
expect(getByTestId('warnings-count').innerHTML).toContain('4')
expect(getByTestId('errors-pc').innerHTML).toContain('29%')
expect(getByTestId('errors-count').innerHTML).toContain('2')
})
it('should not render the component when everything is zero', async () => {
const { getByTestId } = renderWithProviders(
<TraefikResourceStatsCard title="test" errors={0} total={0} warnings={0} linkTo="" />,
)
expect(() => {
getByTestId('success-pc')
}).toThrow('Unable to find an element by: [data-testid="success-pc"]')
expect(() => {
getByTestId('success-count')
}).toThrow('Unable to find an element by: [data-testid="success-count"]')
expect(() => {
getByTestId('warnings-pc')
}).toThrow('Unable to find an element by: [data-testid="warnings-pc"]')
expect(() => {
getByTestId('warnings-count')
}).toThrow('Unable to find an element by: [data-testid="warnings-count"]')
expect(() => {
getByTestId('errors-pc')
}).toThrow('Unable to find an element by: [data-testid="errors-pc"]')
expect(() => {
getByTestId('errors-count')
}).toThrow('Unable to find an element by: [data-testid="errors-count"]')
})
})

View file

@ -0,0 +1,216 @@
import { Box, Card, Flex, H3, Skeleton, styled, Text } from '@traefiklabs/faency'
import { Chart as ChartJs, ArcElement, Tooltip } from 'chart.js'
import { ReactNode, useEffect, useMemo, useState } from 'react'
import { Doughnut } from 'react-chartjs-2'
import { FaArrowRightLong } from 'react-icons/fa6'
import { Link as RouterLink, useNavigate } from 'react-router-dom'
import Status, { colorByStatus, StatusType } from './Status'
import { capitalizeFirstLetter } from 'utils/string'
ChartJs.register(ArcElement, Tooltip)
const Link = styled(RouterLink, {
textDecoration: 'none',
'&:hover': {
textDecoration: 'none',
},
})
type StatsCardType = {
children: ReactNode
}
const StatsCard = ({ children, ...props }: StatsCardType) => (
<Card
css={{
display: 'flex',
flexDirection: 'column',
padding: '16px',
overflow: 'hidden',
}}
{...props}
>
{children}
</Card>
)
export type TraefikResourceStatsType = {
title?: string
errors: number
total: number
warnings: number
}
export type TraefikResourceStatsCardProps = TraefikResourceStatsType & {
linkTo: string
}
export type DataType = {
datasets: {
backgroundColor: string[]
data: (string | number)[]
}[]
labels?: string[]
}
const getPercent = (total: number, value: number) => (total > 0 ? ((value * 100) / total).toFixed(0) : 0)
const STATS_ATTRIBUTES: { status: StatusType; label: string }[] = [
{
status: 'enabled',
label: 'success',
},
{
status: 'warning',
label: 'warnings',
},
{
status: 'disabled',
label: 'errors',
},
]
const CustomLegend = ({
status,
label,
count,
total,
linkTo,
}: {
status: StatusType
label: string
count: number
total: number
linkTo: string
}) => {
return (
<Link to={`${linkTo}?status=${status}`}>
<Flex css={{ alignItems: 'center', p: '$2' }}>
<Status status={status} />
<Flex css={{ flexDirection: 'column', flex: 1 }}>
<Text css={{ fontWeight: 600 }}>{capitalizeFirstLetter(label)}</Text>
<Text size={1} css={{ color: 'hsl(0, 0%, 56%)' }} data-testid={`${label}-pc`}>
{getPercent(total, count)}%
</Text>
</Flex>
<Text size={5} css={{ fontWeight: 700 }} data-testid={`${label}-count`}>
{count}
</Text>
</Flex>
</Link>
)
}
const TraefikResourceStatsCard = ({ title, errors, total, warnings, linkTo }: TraefikResourceStatsCardProps) => {
const navigate = useNavigate()
const defaultData = {
datasets: [
{
backgroundColor: [colorByStatus.enabled],
data: [1],
},
],
}
const [data, setData] = useState<DataType>(defaultData)
const counts = useMemo(
() => ({
success: total - (errors + warnings),
warnings,
errors,
}),
[errors, total, warnings],
)
useEffect(() => {
if (counts.success + counts.warnings + counts.errors === 0) {
setData(defaultData)
return
}
const newData = {
datasets: [
{
backgroundColor: [colorByStatus.enabled, colorByStatus.warning, colorByStatus.error],
data: [counts.success, counts.warnings, counts.errors],
},
],
labels: ['Success', 'Warnings', 'Errors'],
}
setData(newData)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [errors, warnings, total, counts])
const options = {
animation: {
duration: 1000,
},
plugins: {
legend: {
display: false,
},
},
tooltips: {
enabled: true,
},
maintainAspectRatio: false,
onClick: (_, activeEl) => {
navigate(`${linkTo}?status=${STATS_ATTRIBUTES[activeEl[0].index].status}`)
},
}
if (!errors && !total && !warnings) return null
return (
<StatsCard data-testid="card">
{title && (
<Flex css={{ pb: '$3', mb: '$2' }}>
{title && (
<Flex align="center" justify="space-between" css={{ flex: '1' }}>
<H3 css={{ fontSize: '$6' }}>{title}</H3>
<Link to={linkTo as string}>
<Flex align="center" gap={1} css={{ color: '$primary' }}>
<Text css={{ fontWeight: 500, color: '$primary' }}>Explore</Text>
<FaArrowRightLong />
</Flex>
</Link>
</Flex>
)}
</Flex>
)}
<Flex css={{ flex: '1' }}>
<Box css={{ width: '50%' }}>
<Doughnut data={data} options={options} />
</Box>
<Box css={{ width: '50%' }}>
{STATS_ATTRIBUTES.map((i) => (
<CustomLegend key={`${title}-${i.label}`} {...i} count={counts[i.label]} total={total} linkTo={linkTo} />
))}
</Box>
</Flex>
</StatsCard>
)
}
export const StatsCardSkeleton = () => {
return (
<StatsCard>
<Flex gap={2}>
<Skeleton css={{ width: '80%', height: 150 }} />
<Flex direction="column" gap={2} css={{ flex: 1 }}>
<Skeleton />
<Skeleton />
<Skeleton />
</Flex>
</Flex>
</StatsCard>
)
}
export default TraefikResourceStatsCard

View file

@ -0,0 +1,146 @@
import { AriaTable, AriaTbody, AriaTd, AriaTh, AriaThead, AriaTr, Box, Flex, styled } from '@traefiklabs/faency'
import { orderBy } from 'lodash'
import { useContext, useEffect, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { SectionHeader } from 'components/resources/DetailSections'
import SortableTh from 'components/tables/SortableTh'
import { ToastContext } from 'contexts/toasts'
import { MiddlewareDetailType, ServiceDetailType } from 'hooks/use-resource-detail'
import { makeRowRender } from 'pages/http/HttpRouters'
type UsedByRoutersSectionProps = {
data: ServiceDetailType | MiddlewareDetailType
protocol?: string
}
const SkeletonContent = styled(Box, {
backgroundColor: '$slate5',
height: '14px',
minWidth: '50px',
borderRadius: '4px',
margin: '8px',
})
export const UsedByRoutersSkeleton = () => (
<Flex css={{ flexDirection: 'column', mt: '40px' }}>
<SectionHeader />
<AriaTable>
<AriaThead>
<AriaTr>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
<AriaTh>
<SkeletonContent />
</AriaTh>
</AriaTr>
</AriaThead>
<AriaTbody>
<AriaTr css={{ pointerEvents: 'none' }}>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
</AriaTr>
<AriaTr css={{ pointerEvents: 'none' }}>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
<AriaTd>
<SkeletonContent />
</AriaTd>
</AriaTr>
</AriaTbody>
</AriaTable>
</Flex>
)
export const UsedByRoutersSection = ({ data, protocol = 'http' }: UsedByRoutersSectionProps) => {
const renderRow = makeRowRender(protocol)
const [searchParams] = useSearchParams()
const { addToast } = useContext(ToastContext)
const routersFound = useMemo(() => {
let routers = data.routers?.filter((r) => !r.message)
const direction = (searchParams.get('direction') as 'asc' | 'desc' | null) || 'asc'
const sortBy = searchParams.get('sortBy') || 'name'
if (sortBy) routers = orderBy(routers, [sortBy], [direction || 'asc'])
return routers
}, [data, searchParams])
const routersNotFound = useMemo(() => data.routers?.filter((r) => !!r.message), [data])
useEffect(() => {
routersNotFound?.map((error) =>
addToast({
message: error.message,
severity: 'error',
}),
)
}, [addToast, routersNotFound])
if (!routersFound || routersFound.length <= 0) {
return null
}
return (
<Flex css={{ flexDirection: 'column', mt: '$5' }}>
<SectionHeader title="Used by Routers" />
<AriaTable data-testid="routers-table">
<AriaThead>
<AriaTr>
<SortableTh label="Status" css={{ width: '40px' }} isSortable sortByValue="status" />
{protocol !== 'udp' ? <SortableTh css={{ width: '40px' }} label="TLS" /> : null}
{protocol !== 'udp' ? <SortableTh label="Rule" isSortable sortByValue="rule" /> : null}
<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" isSortable sortByValue="priority" />
</AriaTr>
</AriaThead>
<AriaTbody>{routersFound.map(renderRow)}</AriaTbody>
</AriaTable>
</Flex>
)
}

View file

@ -0,0 +1,56 @@
import { AriaTh, CSS, Flex, Label } from '@traefiklabs/faency'
import { useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import SortButton from 'components/buttons/SortButton'
const STYLE_BY_ALIGN_VALUE = {
left: {},
center: {
justifyContent: 'center',
},
right: {
justifyContent: 'flex-end',
},
}
type SortableThProps = {
label: string
isSortable?: boolean
sortByValue?: string
align?: 'left' | 'center' | 'right'
css?: CSS
}
export default function SortableTh({ label, isSortable = false, sortByValue, align = 'left', css }: SortableThProps) {
const wrapperStyle = useMemo(() => STYLE_BY_ALIGN_VALUE[align], [align])
const [searchParams, setSearchParams] = useSearchParams()
const isActive = useMemo(() => (searchParams.get('sortBy') || 'name') === sortByValue, [searchParams, sortByValue])
const order = useMemo(() => (searchParams.get('direction') as 'asc' | 'desc' | '') || 'asc', [searchParams])
const onSort = useCallback(() => {
if (!sortByValue) return
const direction = searchParams.get('direction') || 'asc'
const sortBy = searchParams.get('sortBy') || 'name'
if (!sortBy || sortBy !== sortByValue || direction === 'desc') {
setSearchParams({ ...Object.fromEntries(searchParams.entries()), sortBy: sortByValue, direction: 'asc' })
} else {
setSearchParams({ ...Object.fromEntries(searchParams.entries()), sortBy: sortByValue, direction: 'desc' })
}
}, [sortByValue, searchParams, setSearchParams])
return (
<AriaTh css={css}>
<Flex align="center" css={wrapperStyle}>
{isSortable ? (
<SortButton onClick={onSort} order={isActive ? order : undefined} label={label} />
) : (
<Label>{label}</Label>
)}
</Flex>
</AriaTh>
)
}

View file

@ -0,0 +1,40 @@
import * as React from 'react'
import { ToastState } from 'components/Toast'
function handleHideToast(toast: ToastState): (t: ToastState) => ToastState {
return (t: ToastState): ToastState => {
if (t === toast) {
t.isVisible = false
}
return t
}
}
interface ToastProviderProps {
children: React.ReactNode
}
interface ToastContextProps {
toasts: ToastState[]
addToast: (toast: ToastState) => void
hideToast: (toast: ToastState) => void
}
export const ToastContext = React.createContext({} as ToastContextProps)
export const ToastProvider = (props: ToastProviderProps) => {
const [toasts, setToastList] = React.useState<ToastState[]>([])
const addToast = React.useCallback((toast: ToastState) => {
setToastList((toasts) => [...toasts, toast])
}, [])
const hideToast = React.useCallback((toast: ToastState) => {
setToastList((toasts) => toasts.map(handleHideToast(toast)))
}, [])
const value: ToastContextProps = { toasts, addToast, hideToast }
return <ToastContext.Provider value={value}>{props.children}</ToastContext.Provider>
}

Some files were not shown because too many files have changed in this diff Show more