feat(dashboard): add v2 client detail status (#5381)

This commit is contained in:
fatedier
2026-06-26 21:15:30 +08:00
committed by GitHub
parent 393a533744
commit ae1c0504ec
8 changed files with 320 additions and 102 deletions

View File

@@ -50,7 +50,9 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET") subRouter.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2UserList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET") subRouter.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientList)).Methods("GET")
subRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET") v2EncodedPathRouter := subRouter.NewRoute().Subrouter()
v2EncodedPathRouter.UseEncodedPath()
v2EncodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ClientDetail)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET") subRouter.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyList)).Methods("GET")
subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET") subRouter.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(apiController.APIV2ProxyDetail)).Methods("GET")

View File

@@ -19,6 +19,7 @@ import (
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
"net/url"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -27,6 +28,7 @@ import (
"github.com/fatedier/frp/pkg/metrics/mem" "github.com/fatedier/frp/pkg/metrics/mem"
httppkg "github.com/fatedier/frp/pkg/util/http" httppkg "github.com/fatedier/frp/pkg/util/http"
"github.com/fatedier/frp/server/http/model" "github.com/fatedier/frp/server/http/model"
"github.com/fatedier/frp/server/registry"
) )
const ( const (
@@ -137,7 +139,31 @@ func (c *Controller) APIV2ClientList(ctx *httppkg.Context) (any, error) {
// /api/v2/clients/{key} // /api/v2/clients/{key}
func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) { func (c *Controller) APIV2ClientDetail(ctx *httppkg.Context) (any, error) {
return c.APIClientDetail(ctx) key := ctx.Param("key")
if key == "" {
return nil, fmt.Errorf("missing client key")
}
decodedKey, err := url.PathUnescape(key)
if err != nil {
return nil, fmt.Errorf("invalid client key %q: %w", key, err)
}
key = decodedKey
if c.clientRegistry == nil {
return nil, fmt.Errorf("client registry unavailable")
}
info, ok := c.clientRegistry.GetByKey(key)
if !ok {
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
}
resp := buildClientInfoResp(info)
status := c.buildV2ClientStatus(info)
return model.V2ClientDetailResp{
ClientInfoResp: resp,
Status: status,
}, nil
} }
// /api/v2/proxies // /api/v2/proxies
@@ -366,6 +392,24 @@ func (c *Controller) listV2ProxyStats(proxyType string) []*mem.ProxyStats {
return items return items
} }
func (c *Controller) buildV2ClientStatus(info registry.ClientInfo) model.V2ClientStatusResp {
status := model.V2ClientStatusResp{State: "offline"}
if info.Online {
status.State = "online"
}
user := info.User
clientID := info.ClientID()
for _, ps := range c.listV2ProxyStats("") {
if ps.User != user || ps.ClientID != clientID {
continue
}
status.CurConns += ps.CurConns
status.ProxyCount++
}
return status
}
func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp { func (c *Controller) buildV2ProxyResp(ps *mem.ProxyStats) model.V2ProxyResp {
state := "offline" state := "offline"
var spec any var spec any

View File

@@ -20,6 +20,7 @@ import (
"math" "math"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"testing" "testing"
"github.com/gorilla/mux" "github.com/gorilla/mux"
@@ -146,10 +147,49 @@ func TestAPIV2ClientDetailEnvelope(t *testing.T) {
if resp.Code != http.StatusOK { if resp.Code != http.StatusOK {
t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code) t.Fatalf("status mismatch, want %d got %d", http.StatusOK, resp.Code)
} }
detailResp := decodeResponse[v2EnvelopeForTest[model.ClientInfoResp]](t, resp) detailResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" { if detailResp.Data.User != "alice" || detailResp.Data.ClientID != "client-a" {
t.Fatalf("client detail mismatch: %#v", detailResp.Data) t.Fatalf("client detail mismatch: %#v", detailResp.Data)
} }
if detailResp.Data.Status.State != "online" || detailResp.Data.Status.CurConns != 5 || detailResp.Data.Status.ProxyCount != 2 {
t.Fatalf("client detail status mismatch: %#v", detailResp.Data.Status)
}
}
func TestAPIV2ClientDetailEncodedKey(t *testing.T) {
oldStatsCollector := mem.StatsCollector
mem.StatsCollector = &fakeStatsCollector{
proxies: map[string]*mem.ProxyStats{
"tcp-url": {
Name: "tcp-url",
Type: "tcp",
User: "url",
ClientID: "client/a?b#c",
CurConns: 7,
},
},
}
t.Cleanup(func() {
mem.StatsCollector = oldStatsCollector
})
clientRegistry := registry.NewClientRegistry()
clientRegistry.Register("url", "client/a?b#c", "run-url", "url-host", "1.0.0", "127.0.0.4", "v2")
controller := NewController(&v1.ServerConfig{}, clientRegistry, serverproxy.NewManager())
router := newV2TestRouter(controller)
encodedKey := url.PathEscape("url.client/a?b#c")
resp := performRequest(router, "/api/v2/clients/"+encodedKey)
if resp.Code != http.StatusOK {
t.Fatalf("encoded client key status mismatch, want %d got %d, body: %s", http.StatusOK, resp.Code, resp.Body.String())
}
encodedResp := decodeResponse[v2EnvelopeForTest[model.V2ClientDetailResp]](t, resp)
if encodedResp.Data.User != "url" || encodedResp.Data.ClientID != "client/a?b#c" {
t.Fatalf("encoded client detail mismatch: %#v", encodedResp.Data)
}
if encodedResp.Data.Status.CurConns != 7 || encodedResp.Data.Status.ProxyCount != 1 {
t.Fatalf("encoded client detail status mismatch: %#v", encodedResp.Data.Status)
}
} }
func TestAPIV2ProxyListDetailAndUsers(t *testing.T) { func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
@@ -186,8 +226,13 @@ func TestAPIV2ProxyListDetailAndUsers(t *testing.T) {
if userResp.Data.Total != 3 { if userResp.Data.Total != 3 {
t.Fatalf("user total mismatch: %#v", userResp.Data) t.Fatalf("user total mismatch: %#v", userResp.Data)
} }
expectedProxyCounts := map[string]int{
"": 1,
"alice": 2,
"bob": 1,
}
for _, item := range userResp.Data.Items { for _, item := range userResp.Data.Items {
if item.ClientCount != 1 || item.ProxyCount != 1 { if item.ClientCount != 1 || item.ProxyCount != expectedProxyCounts[item.User] {
t.Fatalf("user counts mismatch: %#v", item) t.Fatalf("user counts mismatch: %#v", item)
} }
} }
@@ -322,6 +367,14 @@ func newV2TestController(t *testing.T) *Controller {
ClientID: "client-a", ClientID: "client-a",
TodayTrafficIn: 30, TodayTrafficIn: 30,
TodayTrafficOut: 40, TodayTrafficOut: 40,
CurConns: 2,
},
"http-alice": {
Name: "http-alice",
Type: "http",
User: "alice",
ClientID: "client-a",
CurConns: 3,
}, },
"udp-bob": { "udp-bob": {
Name: "udp-bob", Name: "udp-bob",
@@ -348,7 +401,9 @@ func newV2TestRouter(controller *Controller) *mux.Router {
router := mux.NewRouter() router := mux.NewRouter()
router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/users", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2UserList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/clients", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet) encodedPathRouter := router.NewRoute().Subrouter()
encodedPathRouter.UseEncodedPath()
encodedPathRouter.HandleFunc("/api/v2/clients/{key}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ClientDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet) router.HandleFunc("/api/v2/proxies", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyList)).Methods(http.MethodGet)
router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet) router.HandleFunc("/api/v2/proxies/{name}", httppkg.MakeHTTPHandlerFuncV2(controller.APIV2ProxyDetail)).Methods(http.MethodGet)
router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet) router.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(controller.APIClientList)).Methods(http.MethodGet)

View File

@@ -27,6 +27,17 @@ type V2UserResp struct {
ProxyCount int `json:"proxyCount"` ProxyCount int `json:"proxyCount"`
} }
type V2ClientDetailResp struct {
ClientInfoResp
Status V2ClientStatusResp `json:"status"`
}
type V2ClientStatusResp struct {
State string `json:"phase"`
CurConns int64 `json:"curConns"`
ProxyCount int64 `json:"proxyCount"`
}
type V2ProxyResp struct { type V2ProxyResp struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`

View File

@@ -24,3 +24,7 @@ export const getClientsV2 = (params: ClientListV2Params = {}) => {
export const getClient = (key: string) => { export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`) return http.get<ClientInfoData>(`../api/clients/${key}`)
} }
export const getClientV2 = (key: string) => {
return http.getV2<ClientInfoData>(`../api/v2/clients/${encodeURIComponent(key)}`)
}

View File

@@ -12,6 +12,13 @@ export interface ClientInfoData {
lastConnectedAt: number lastConnectedAt: number
disconnectedAt?: number disconnectedAt?: number
online: boolean online: boolean
status?: ClientStatus
}
export interface ClientStatus {
phase: 'online' | 'offline'
curConns: number
proxyCount: number
} }
export interface ClientListV2Params { export interface ClientListV2Params {

View File

@@ -1,5 +1,5 @@
import { formatDistanceToNow } from './format' import { formatDistanceToNow } from './format'
import type { ClientInfoData } from '../types/client' import type { ClientInfoData, ClientStatus } from '../types/client'
export class Client { export class Client {
key: string key: string
@@ -15,6 +15,7 @@ export class Client {
lastConnectedAt: Date lastConnectedAt: Date
disconnectedAt?: Date disconnectedAt?: Date
online: boolean online: boolean
status: ClientStatus
constructor(data: ClientInfoData) { constructor(data: ClientInfoData) {
this.key = data.key this.key = data.key
@@ -37,6 +38,11 @@ export class Client {
this.disconnectedAt = new Date(data.disconnectedAt * 1000) this.disconnectedAt = new Date(data.disconnectedAt * 1000)
} }
this.online = data.online this.online = data.online
this.status = data.status || {
phase: this.online ? 'online' : 'offline',
curConns: 0,
proxyCount: 0,
}
} }
get displayName(): string { get displayName(): string {

View File

@@ -55,7 +55,7 @@
<div class="info-section"> <div class="info-section">
<div class="info-item"> <div class="info-item">
<span class="info-label">Connections</span> <span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span> <span class="info-value">{{ client.status.curConns }}</span>
</div> </div>
<div class="info-item"> <div class="info-item">
<span class="info-label">Run ID</span> <span class="info-label">Run ID</span>
@@ -85,7 +85,7 @@
<div class="proxies-header"> <div class="proxies-header">
<div class="proxies-title"> <div class="proxies-title">
<h2>Proxies</h2> <h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span> <span class="proxies-count">{{ total }}</span>
</div> </div>
<el-input <el-input
v-model="proxySearch" v-model="proxySearch"
@@ -100,21 +100,32 @@
<el-icon class="is-loading"><Loading /></el-icon> <el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span> <span>Loading...</span>
</div> </div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list"> <div v-else-if="proxies.length > 0" class="proxies-list">
<ProxyCard <ProxyCard
v-for="proxy in filteredProxies" v-for="proxy in proxies"
:key="proxy.name" :key="`${proxy.type}:${proxy.name}`"
:proxy="proxy" :proxy="proxy"
show-type show-type
/> />
</div> </div>
<div v-else-if="clientProxies.length > 0" class="empty-state"> <div v-else-if="proxySearch.trim()" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p> <p>No proxies match "{{ proxySearch }}"</p>
</div> </div>
<div v-else class="empty-state"> <div v-else class="empty-state">
<p>No proxies found</p> <p>No proxies found</p>
</div> </div>
</div> </div>
<div v-if="total > 0" class="pagination-section">
<ElPagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="onPageChange"
@size-change="onPageSizeChange"
/>
</div>
</div> </div>
</template> </template>
@@ -130,13 +141,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage, ElPagination } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue' import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client' import { Client } from '../utils/client'
import { getClient } from '../api/client' import { getClientV2 } from '../api/client'
import { getProxiesByType } from '../api/proxy' import { getProxiesV2 } from '../api/proxy'
import { import {
BaseProxy, BaseProxy,
TCPProxy, TCPProxy,
@@ -149,6 +160,7 @@ import {
} from '../utils/proxy' } from '../utils/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue' import ProxyCard from '../components/ProxyCard.vue'
import type { ProxyStatsInfo } from '../types/proxy'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -163,119 +175,190 @@ const goBack = () => {
} }
} }
const proxiesLoading = ref(false) const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([]) const proxies = ref<BaseProxy[]>([])
const proxySearch = ref('') const proxySearch = ref('')
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
let requestSeq = 0
let searchDebounceTimer: number | null = null
let serverInfo: { type ServerInfoLite = {
vhostHTTPPort: number vhostHTTPPort: number
vhostHTTPSPort: number vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number tcpmuxHTTPConnectPort: number
subdomainHost: string subdomainHost: string
} | null = null
const clientProxies = computed(() => {
if (!client.value) return []
return allProxies.value.filter(
(p) =>
p.clientID === client.value!.clientID && p.user === client.value!.user,
)
})
const filteredProxies = computed(() => {
if (!proxySearch.value) return clientProxies.value
const search = proxySearch.value.toLowerCase()
return clientProxies.value.filter(
(p) =>
p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search),
)
})
const totalConnections = computed(() => {
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
})
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
} }
const fetchClient = async () => { let serverInfoPromise: Promise<ServerInfoLite> | null = null
const fetchServerInfo = (): Promise<ServerInfoLite> => {
if (!serverInfoPromise) {
serverInfoPromise = getServerInfo().catch((err) => {
serverInfoPromise = null
throw err
})
}
return serverInfoPromise
}
const fetchClient = async (): Promise<boolean> => {
const key = route.params.key as string const key = route.params.key as string
if (!key) { if (!key) {
loading.value = false loading.value = false
return return false
} }
try { try {
const data = await getClient(key) const data = await getClientV2(key)
client.value = new Client(data) client.value = new Client(data)
return true
} catch (error: any) { } catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message) ElMessage.error('Failed to fetch client: ' + error.message)
return false
} finally { } finally {
loading.value = false loading.value = false
} }
} }
const fetchProxies = async () => { const convertProxy = async (
proxiesLoading.value = true proxy: ProxyStatsInfo,
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp'] ): Promise<BaseProxy | null> => {
const proxies: BaseProxy[] = [] const type = proxy.type || ''
try {
const info = await fetchServerInfo()
for (const type of proxyTypes) {
try {
const json = await getProxiesByType(type)
if (!json.proxies) continue
if (type === 'tcp') { if (type === 'tcp') {
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p))) return new TCPProxy(proxy)
} else if (type === 'udp') { }
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p))) if (type === 'udp') {
} else if (type === 'http' && info?.vhostHTTPPort) { return new UDPProxy(proxy)
proxies.push( }
...json.proxies.map( if (type === 'http') {
(p: any) => const info = await fetchServerInfo()
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost), if (info && info.vhostHTTPPort) {
), return new HTTPProxy(proxy, info.vhostHTTPPort, info.subdomainHost)
) }
} else if (type === 'https' && info?.vhostHTTPSPort) { return null
proxies.push( }
...json.proxies.map( if (type === 'https') {
(p: any) => const info = await fetchServerInfo()
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost), if (info && info.vhostHTTPSPort) {
), return new HTTPSProxy(proxy, info.vhostHTTPSPort, info.subdomainHost)
) }
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) { return null
proxies.push( }
...json.proxies.map( if (type === 'tcpmux') {
(p: any) => const info = await fetchServerInfo()
new TCPMuxProxy( if (info && info.tcpmuxHTTPConnectPort) {
p, return new TCPMuxProxy(
proxy,
info.tcpmuxHTTPConnectPort, info.tcpmuxHTTPConnectPort,
info.subdomainHost, info.subdomainHost,
),
),
) )
} else if (type === 'stcp') {
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
} else if (type === 'sudp') {
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
} }
} catch { return null
// Ignore
} }
if (type === 'stcp') {
return new STCPProxy(proxy)
} }
allProxies.value = proxies if (type === 'sudp') {
} catch { return new SUDPProxy(proxy)
// Ignore }
const bp = new BaseProxy(proxy)
bp.type = type
return bp
}
const convertProxies = async (items: ProxyStatsInfo[]): Promise<BaseProxy[]> => {
const converted = await Promise.all(items.map((item) => convertProxy(item)))
return converted.filter((item): item is BaseProxy => item !== null)
}
const fetchProxies = async () => {
if (!client.value) return
const seq = ++requestSeq
proxiesLoading.value = true
try {
const q = proxySearch.value.trim()
const data = await getProxiesV2({
page: page.value,
pageSize: pageSize.value,
q: q || undefined,
clientID: client.value.clientID,
user: client.value.user,
})
if (seq !== requestSeq) return
const maxPage = Math.max(1, Math.ceil(data.total / data.pageSize))
if (data.items.length === 0 && data.total > 0 && data.page > maxPage) {
page.value = maxPage
await fetchProxies()
return
}
const converted = await convertProxies(data.items)
if (seq !== requestSeq) return
proxies.value = converted
total.value = data.total
page.value = data.page
pageSize.value = data.pageSize
} catch (error: any) {
if (seq !== requestSeq) return
ElMessage.error('Failed to fetch proxies: ' + error.message)
} finally { } finally {
if (seq === requestSeq) {
proxiesLoading.value = false proxiesLoading.value = false
} }
} }
}
const clearSearchDebounce = () => {
if (searchDebounceTimer !== null) {
window.clearTimeout(searchDebounceTimer)
searchDebounceTimer = null
}
}
const invalidateProxyRequests = () => {
requestSeq++
proxiesLoading.value = false
}
const resetPageAndFetch = () => {
clearSearchDebounce()
page.value = 1
fetchProxies()
}
const onPageChange = (value: number) => {
clearSearchDebounce()
page.value = value
fetchProxies()
}
const onPageSizeChange = (value: number) => {
pageSize.value = value
resetPageAndFetch()
}
watch(proxySearch, () => {
clearSearchDebounce()
invalidateProxyRequests()
page.value = 1
searchDebounceTimer = window.setTimeout(() => {
searchDebounceTimer = null
fetchProxies()
}, 300)
})
onUnmounted(() => {
clearSearchDebounce()
})
onMounted(async () => {
const ok = await fetchClient()
if (!ok || !client.value) return
onMounted(() => {
fetchClient()
fetchProxies() fetchProxies()
}) })
</script> </script>
@@ -483,6 +566,12 @@ html.dark .status-badge.online {
padding: 16px; padding: 16px;
} }
.pagination-section {
display: flex;
justify-content: center;
padding: 0 20px 20px;
}
.proxies-list { .proxies-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;