mirror of
https://github.com/fatedier/frp.git
synced 2026-07-16 09:19:17 +08:00
feat(dashboard): add v2 client detail status (#5381)
This commit is contained in:
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user