web/frps: add detailed client and proxy views with enhanced tracking (#5144)

This commit is contained in:
fatedier 2026-01-31 02:18:35 +08:00 committed by GitHub
parent 5dd70ace6b
commit 266c492b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 3000 additions and 923 deletions

View File

@ -1,7 +1,7 @@
## Features ## Features
* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments. * frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
* Redesigned the frps web dashboard with a modern UI, dark mode support, and improved navigation. * Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
## Fixes ## Fixes

View File

@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() {
} }
} }
func (m *serverMetrics) NewProxy(name string, proxyType string) { func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
for _, v := range m.ms { for _, v := range m.ms {
v.NewProxy(name, proxyType) v.NewProxy(name, proxyType, user, clientID)
} }
} }

View File

@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() {
m.info.ClientCounts.Dec(1) m.info.ClientCounts.Dec(1)
} }
func (m *serverMetrics) NewProxy(name string, proxyType string) { func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
counter, ok := m.info.ProxyTypeCounts[proxyType] counter, ok := m.info.ProxyTypeCounts[proxyType]
@ -119,6 +119,8 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) {
} }
m.info.ProxyStatistics[name] = proxyStats m.info.ProxyStatistics[name] = proxyStats
} }
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = time.Now() proxyStats.LastStartTime = time.Now()
} }
@ -214,6 +216,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
ps := &ProxyStats{ ps := &ProxyStats{
Name: name, Name: name,
Type: proxyStats.ProxyType, Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()), CurConns: int64(proxyStats.CurConns.Count()),
@ -245,6 +249,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
res = &ProxyStats{ res = &ProxyStats{
Name: name, Name: name,
Type: proxyStats.ProxyType, Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(), TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(), TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()), CurConns: int64(proxyStats.CurConns.Count()),
@ -260,6 +266,31 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
return return
} }
func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
m.mu.Lock()
defer m.mu.Unlock()
proxyStats, ok := m.info.ProxyStatistics[proxyName]
if ok {
res = &ProxyStats{
Name: proxyName,
Type: proxyStats.ProxyType,
User: proxyStats.User,
ClientID: proxyStats.ClientID,
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
CurConns: int64(proxyStats.CurConns.Count()),
}
if !proxyStats.LastStartTime.IsZero() {
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
}
if !proxyStats.LastCloseTime.IsZero() {
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
}
}
return
}
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) { func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View File

@ -35,6 +35,8 @@ type ServerStats struct {
type ProxyStats struct { type ProxyStats struct {
Name string Name string
Type string Type string
User string
ClientID string
TodayTrafficIn int64 TodayTrafficIn int64
TodayTrafficOut int64 TodayTrafficOut int64
LastStartTime string LastStartTime string
@ -51,6 +53,8 @@ type ProxyTrafficInfo struct {
type ProxyStatistics struct { type ProxyStatistics struct {
Name string Name string
ProxyType string ProxyType string
User string
ClientID string
TrafficIn metric.DateCounter TrafficIn metric.DateCounter
TrafficOut metric.DateCounter TrafficOut metric.DateCounter
CurConns metric.Counter CurConns metric.Counter
@ -78,6 +82,7 @@ type Collector interface {
GetServer() *ServerStats GetServer() *ServerStats
GetProxiesByType(proxyType string) []*ProxyStats GetProxiesByType(proxyType string) []*ProxyStats
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
GetProxyByName(proxyName string) *ProxyStats
GetProxyTraffic(name string) *ProxyTrafficInfo GetProxyTraffic(name string) *ProxyTrafficInfo
ClearOfflineProxies() (int, int) ClearOfflineProxies() (int, int)
} }

View File

@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() {
m.clientCount.Dec() m.clientCount.Dec()
} }
func (m *serverMetrics) NewProxy(name string, proxyType string) { func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {
m.proxyCount.WithLabelValues(proxyType).Inc() m.proxyCount.WithLabelValues(proxyType).Inc()
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc() m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
} }

View File

@ -117,7 +117,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
if userFilter != "" && info.User != userFilter { if userFilter != "" && info.User != userFilter {
continue continue
} }
if clientIDFilter != "" && info.ClientID != clientIDFilter { if clientIDFilter != "" && info.ClientID() != clientIDFilter {
continue continue
} }
if runIDFilter != "" && info.RunID != runIDFilter { if runIDFilter != "" && info.RunID != runIDFilter {
@ -204,6 +204,48 @@ func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
return trafficResp, nil return trafficResp, nil
} }
// /api/proxies/:name
func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
name := ctx.Param("name")
ps := mem.StatsCollector.GetProxyByName(name)
if ps == nil {
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
}
proxyInfo := GetProxyStatsResp{
Name: ps.Name,
User: ps.User,
ClientID: ps.ClientID,
TodayTrafficIn: ps.TodayTrafficIn,
TodayTrafficOut: ps.TodayTrafficOut,
CurConns: ps.CurConns,
LastStartTime: ps.LastStartTime,
LastCloseTime: ps.LastCloseTime,
}
if pxy, ok := c.pxyManager.GetByName(name); ok {
content, err := json.Marshal(pxy.GetConfigurer())
if err != nil {
log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Conf = getConfByType(ps.Type)
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
}
proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{
clientVersion: &proxyInfo.ClientVersion,
}, pxy)
} else {
proxyInfo.Status = "offline"
}
return proxyInfo, nil
}
// DELETE /api/proxies?status=offline // DELETE /api/proxies?status=offline
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) { func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
status := ctx.Query("status") status := ctx.Query("status")
@ -219,7 +261,10 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType) proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats)) proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
for _, ps := range proxyStats { for _, ps := range proxyStats {
proxyInfo := &ProxyStatsInfo{} proxyInfo := &ProxyStatsInfo{
User: ps.User,
ClientID: ps.ClientID,
}
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok { if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
content, err := json.Marshal(pxy.GetConfigurer()) content, err := json.Marshal(pxy.GetConfigurer())
if err != nil { if err != nil {
@ -232,9 +277,9 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
continue continue
} }
proxyInfo.Status = "online" proxyInfo.Status = "online"
if pxy.GetLoginMsg() != nil { c.fillProxyClientInfo(&proxyClientInfo{
proxyInfo.ClientVersion = pxy.GetLoginMsg().Version clientVersion: &proxyInfo.ClientVersion,
} }, pxy)
} else { } else {
proxyInfo.Status = "offline" proxyInfo.Status = "offline"
} }
@ -256,6 +301,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
code = 404 code = 404
msg = "no proxy info found" msg = "no proxy info found"
} else { } else {
proxyInfo.User = ps.User
proxyInfo.ClientID = ps.ClientID
if pxy, ok := c.pxyManager.GetByName(proxyName); ok { if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
content, err := json.Marshal(pxy.GetConfigurer()) content, err := json.Marshal(pxy.GetConfigurer())
if err != nil { if err != nil {
@ -290,7 +337,7 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
resp := ClientInfoResp{ resp := ClientInfoResp{
Key: info.Key, Key: info.Key,
User: info.User, User: info.User,
ClientID: info.ClientID, ClientID: info.ClientID(),
RunID: info.RunID, RunID: info.RunID,
Hostname: info.Hostname, Hostname: info.Hostname,
ClientIP: info.IP, ClientIP: info.IP,
@ -304,6 +351,37 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
return resp return resp
} }
type proxyClientInfo struct {
user *string
clientID *string
clientVersion *string
}
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
loginMsg := pxy.GetLoginMsg()
if loginMsg == nil {
return
}
if proxyInfo.user != nil {
*proxyInfo.user = loginMsg.User
}
if proxyInfo.clientVersion != nil {
*proxyInfo.clientVersion = loginMsg.Version
}
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
if proxyInfo.clientID != nil {
*proxyInfo.clientID = info.ClientID()
}
return
}
if proxyInfo.clientID != nil {
*proxyInfo.clientID = loginMsg.ClientID
if *proxyInfo.clientID == "" {
*proxyInfo.clientID = loginMsg.RunID
}
}
}
func toUnix(t time.Time) int64 { func toUnix(t time.Time) int64 {
if t.IsZero() { if t.IsZero() {
return 0 return 0

View File

@ -98,6 +98,8 @@ type XTCPOutConf struct {
type ProxyStatsInfo struct { type ProxyStatsInfo struct {
Name string `json:"name"` Name string `json:"name"`
Conf any `json:"conf"` Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"` ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"` TodayTrafficOut int64 `json:"todayTrafficOut"`
@ -115,6 +117,9 @@ type GetProxyInfoResp struct {
type GetProxyStatsResp struct { type GetProxyStatsResp struct {
Name string `json:"name"` Name string `json:"name"`
Conf any `json:"conf"` Conf any `json:"conf"`
User string `json:"user,omitempty"`
ClientID string `json:"clientID,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
TodayTrafficIn int64 `json:"todayTrafficIn"` TodayTrafficIn int64 `json:"todayTrafficIn"`
TodayTrafficOut int64 `json:"todayTrafficOut"` TodayTrafficOut int64 `json:"todayTrafficOut"`
CurConns int64 `json:"curConns"` CurConns int64 `json:"curConns"`

View File

@ -405,7 +405,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
} else { } else {
resp.RemoteAddr = remoteAddr resp.RemoteAddr = remoteAddr
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType) xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType) clientID := ctl.loginMsg.ClientID
if clientID == "" {
clientID = ctl.loginMsg.RunID
}
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
} }
_ = ctl.msgDispatcher.Send(resp) _ = ctl.msgDispatcher.Send(resp)
} }

View File

@ -7,7 +7,7 @@ import (
type ServerMetrics interface { type ServerMetrics interface {
NewClient() NewClient()
CloseClient() CloseClient()
NewProxy(name string, proxyType string) NewProxy(name string, proxyType string, user string, clientID string)
CloseProxy(name string, proxyType string) CloseProxy(name string, proxyType string)
OpenConnection(name string, proxyType string) OpenConnection(name string, proxyType string)
CloseConnection(name string, proxyType string) CloseConnection(name string, proxyType string)
@ -29,7 +29,7 @@ type noopServerMetrics struct{}
func (noopServerMetrics) NewClient() {} func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {} func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string) {} func (noopServerMetrics) NewProxy(string, string, string, string) {}
func (noopServerMetrics) CloseProxy(string, string) {} func (noopServerMetrics) CloseProxy(string, string) {}
func (noopServerMetrics) OpenConnection(string, string) {} func (noopServerMetrics) OpenConnection(string, string) {}
func (noopServerMetrics) CloseConnection(string, string) {} func (noopServerMetrics) CloseConnection(string, string) {}

View File

@ -24,7 +24,7 @@ import (
type ClientInfo struct { type ClientInfo struct {
Key string Key string
User string User string
ClientID string RawClientID string
RunID string RunID string
Hostname string Hostname string
IP string IP string
@ -34,8 +34,8 @@ type ClientInfo struct {
Online bool Online bool
} }
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (or runID if clientID is empty). // ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty).
// Entries without an explicit clientID are removed on disconnect to avoid stale offline records. // Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.
type ClientRegistry struct { type ClientRegistry struct {
mu sync.RWMutex mu sync.RWMutex
clients map[string]*ClientInfo clients map[string]*ClientInfo
@ -50,17 +50,17 @@ func NewClientRegistry() *ClientRegistry {
} }
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client. // Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr string) (key string, conflict bool) { func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
if runID == "" { if runID == "" {
return "", false return "", false
} }
effectiveID := clientID effectiveID := rawClientID
if effectiveID == "" { if effectiveID == "" {
effectiveID = runID effectiveID = runID
} }
key = cr.composeClientKey(user, effectiveID) key = cr.composeClientKey(user, effectiveID)
enforceUnique := clientID != "" enforceUnique := rawClientID != ""
now := time.Now() now := time.Now()
cr.mu.Lock() cr.mu.Lock()
@ -75,7 +75,6 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s
info = &ClientInfo{ info = &ClientInfo{
Key: key, Key: key,
User: user, User: user,
ClientID: clientID,
FirstConnectedAt: now, FirstConnectedAt: now,
} }
cr.clients[key] = info cr.clients[key] = info
@ -83,6 +82,7 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s
delete(cr.runIndex, info.RunID) delete(cr.runIndex, info.RunID)
} }
info.RawClientID = rawClientID
info.RunID = runID info.RunID = runID
info.Hostname = hostname info.Hostname = hostname
info.IP = remoteAddr info.IP = remoteAddr
@ -107,7 +107,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
return return
} }
if info, ok := cr.clients[key]; ok && info.RunID == runID { if info, ok := cr.clients[key]; ok && info.RunID == runID {
if info.ClientID == "" { if info.RawClientID == "" {
delete(cr.clients, key) delete(cr.clients, key)
} else { } else {
info.RunID = "" info.RunID = ""
@ -131,7 +131,7 @@ func (cr *ClientRegistry) List() []ClientInfo {
return result return result
} }
// GetByKey retrieves a client by its composite key ({user}.{clientID} or runID fallback). // GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) { func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
cr.mu.RLock() cr.mu.RLock()
defer cr.mu.RUnlock() defer cr.mu.RUnlock()
@ -143,6 +143,30 @@ func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
return *info, true return *info, true
} }
// ClientID returns the resolved client identifier for external use.
func (info ClientInfo) ClientID() string {
if info.RawClientID != "" {
return info.RawClientID
}
return info.RunID
}
// GetByRunID retrieves a client by its run ID.
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
key, ok := cr.runIndex[runID]
if !ok {
return ClientInfo{}, false
}
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
return *info, true
}
func (cr *ClientRegistry) composeClientKey(user, id string) string { func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch { switch {
case user == "": case user == "":

View File

@ -99,7 +99,7 @@ type Service struct {
// Manage all controllers // Manage all controllers
ctlManager *ControlManager ctlManager *ControlManager
// Track logical clients keyed by user.clientID. // Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
clientRegistry *registry.ClientRegistry clientRegistry *registry.ClientRegistry
// Manage all proxies // Manage all proxies
@ -709,6 +709,7 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET") subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET") subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET") subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET") subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET") subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")

View File

@ -11,6 +11,8 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog'] ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider'] ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
@ -18,21 +20,15 @@ declare module 'vue' {
ElFormItem: typeof import('element-plus/es')['ElFormItem'] ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElOption: typeof import('element-plus/es')['ElOption']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm'] ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow'] ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTabPane: typeof import('element-plus/es')['ElTabPane']
ElTabs: typeof import('element-plus/es')['ElTabs']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText'] ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/StatCard.vue')['default'] StatCard: typeof import('./src/components/StatCard.vue')['default']

View File

@ -621,19 +621,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@eslint/eslintrc/node_modules/espree": { "node_modules/@eslint/eslintrc/node_modules/espree": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@ -653,9 +640,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "8.56.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz",
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -678,13 +665,14 @@
} }
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.11.14", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==",
"deprecated": "Use @eslint/config-array instead",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@humanwhocodes/object-schema": "^2.0.2", "@humanwhocodes/object-schema": "^2.0.3",
"debug": "^4.3.1", "debug": "^4.3.1",
"minimatch": "^3.0.5" "minimatch": "^3.0.5"
}, },
@ -707,9 +695,10 @@
} }
}, },
"node_modules/@humanwhocodes/object-schema": { "node_modules/@humanwhocodes/object-schema": {
"version": "2.0.2", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==",
"deprecated": "Use @eslint/object-schema instead",
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
@ -1582,9 +1571,9 @@
} }
}, },
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.5.6", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==", "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1596,17 +1585,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-fTwGQUnjhoYHeSF6m5pWNkzmDDdsKELYrOBxhjMrofPqCkoC2k3B2wvGHFxa1CTIqkEn88nlW1HVMztjo2K8Hg==", "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.5.1", "@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.20.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.20.0", "@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.20.0", "@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.20.0", "@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"ignore": "^5.2.4", "ignore": "^5.2.4",
@ -1632,16 +1621,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
"integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.20.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.20.0", "@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/visitor-keys": "6.20.0", "@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@ -1661,14 +1650,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==", "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.20.0" "@typescript-eslint/visitor-keys": "6.21.0"
}, },
"engines": { "engines": {
"node": "^16.0.0 || >=18.0.0" "node": "^16.0.0 || >=18.0.0"
@ -1679,14 +1668,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==", "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "6.20.0", "@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.20.0", "@typescript-eslint/utils": "6.21.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^1.0.1" "ts-api-utils": "^1.0.1"
}, },
@ -1707,9 +1696,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==", "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -1721,14 +1710,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==", "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.20.0", "@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"globby": "^11.1.0", "globby": "^11.1.0",
"is-glob": "^4.0.3", "is-glob": "^4.0.3",
@ -1766,18 +1755,18 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==", "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12", "@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0", "@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.20.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.20.0", "@typescript-eslint/typescript-estree": "6.21.0",
"semver": "^7.5.4" "semver": "^7.5.4"
}, },
"engines": { "engines": {
@ -1792,13 +1781,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "6.20.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==", "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.20.0", "@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1" "eslint-visitor-keys": "^3.4.1"
}, },
"engines": { "engines": {
@ -1809,19 +1798,6 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
@ -2495,9 +2471,9 @@
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "6.0.5", "version": "6.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz",
"integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2512,9 +2488,9 @@
} }
}, },
"node_modules/cross-spawn/node_modules/semver": { "node_modules/cross-spawn/node_modules/semver": {
"version": "5.7.1", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@ -3057,17 +3033,18 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "8.56.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==",
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4", "@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.56.0", "@eslint/js": "8.57.1",
"@humanwhocodes/config-array": "^0.11.13", "@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8", "@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0", "@ungap/structured-clone": "^1.2.0",
@ -3235,13 +3212,16 @@
} }
}, },
"node_modules/eslint-visitor-keys": { "node_modules/eslint-visitor-keys": {
"version": "3.3.0", "version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/acorn-jsx": { "node_modules/eslint/node_modules/acorn-jsx": {
@ -3308,9 +3288,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint/node_modules/cross-spawn": { "node_modules/eslint/node_modules/cross-spawn": {
"version": "7.0.3", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -3352,19 +3332,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/espree": { "node_modules/eslint/node_modules/espree": {
"version": "9.6.1", "version": "9.6.1",
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
@ -4433,9 +4400,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4543,15 +4510,15 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.21", "version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
@ -4654,9 +4621,9 @@
} }
}, },
"node_modules/minimatch/node_modules/brace-expansion": { "node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -4771,9 +4738,9 @@
} }
}, },
"node_modules/normalize-package-data/node_modules/semver": { "node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.1", "version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {

View File

@ -1,11 +1,20 @@
<template> <template>
<div id="app"> <div id="app">
<header class="header"> <header class="header">
<div class="header-content">
<div class="header-top"> <div class="header-top">
<div class="brand"> <div class="brand-section">
<a href="#" @click.prevent="router.push('/')">frp</a> <div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div> </div>
<div class="header-actions"> <span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a <a
class="github-link" class="github-link"
href="https://github.com/fatedier/frp" href="https://github.com/fatedier/frp"
@ -15,29 +24,32 @@
<GitHubIcon class="github-icon" /> <GitHubIcon class="github-icon" />
</a> </a>
<el-switch <el-switch
v-model="darkmodeSwitch" v-model="isDark"
inline-prompt inline-prompt
:active-icon="Moon" :active-icon="Moon"
:inactive-icon="Sunny" :inactive-icon="Sunny"
@change="toggleDark"
class="theme-switch" class="theme-switch"
/> />
</div> </div>
</div> </div>
<nav class="header-nav">
<el-menu <nav class="nav-bar">
:default-active="currentRoute" <router-link to="/" class="nav-link" active-class="active"
mode="horizontal" >Overview</router-link
:ellipsis="false" >
@select="handleSelect" <router-link to="/clients" class="nav-link" active-class="active"
class="nav-menu" >Clients</router-link
>
<router-link
to="/proxies"
class="nav-link"
:class="{ active: route.path.startsWith('/proxies') }"
>Proxies</router-link
> >
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/clients">Clients</el-menu-item>
<el-menu-item index="/proxies">Proxies</el-menu-item>
</el-menu>
</nav> </nav>
</div>
</header> </header>
<main id="content"> <main id="content">
<router-view></router-view> <router-view></router-view>
</main> </main>
@ -45,256 +57,204 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core' import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component' import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
const router = useRouter()
const route = useRoute() const route = useRoute()
const isDark = useDark() const isDark = useDark()
const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark)
const currentRoute = computed(() => { const currentRouteName = computed(() => {
// Normalize /proxies/:type to /proxies for menu highlighting if (route.path === '/') return 'Overview'
if (route.path.startsWith('/proxies')) { if (route.path.startsWith('/clients')) return 'Clients'
return '/proxies' if (route.path.startsWith('/proxies')) return 'Proxies'
} return ''
return route.path
}) })
const handleSelect = (key: string) => {
router.push(key)
}
</script> </script>
<style> <style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body { body {
margin: 0; margin: 0;
font-family: font-family:
-apple-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
BlinkMacSystemFont, Arial, sans-serif;
Helvetica Neue,
sans-serif;
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f2f2f2; background-color: var(--el-bg-color-page);
}
html.dark #app {
background: #1a1a2e;
} }
.header { .header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background: #fff; background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
} }
html.dark .header { .header-content {
background: #1e1e2d; max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
} }
.header-top { .header-top {
height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 48px;
padding: 0 32px;
} }
.brand a { .brand-section {
color: #303133; display: flex;
font-size: 20px; align-items: center;
font-weight: 700; gap: 12px;
text-decoration: none; }
.logo-wrapper {
display: flex;
align-items: center;
}
.logo-icon {
width: 32px;
height: 32px;
}
.divider {
color: var(--header-border);
font-size: 24px;
font-weight: 200;
}
.brand-name {
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
html.dark .brand a { .badge {
color: #e5e7eb; font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
} }
.brand a:hover { .header-controls {
color: #409eff;
}
.header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.github-link { .github-link {
width: 26px;
height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px; justify-content: center;
border-radius: 6px; border-radius: 50%;
transition: all 0.2s; color: var(--text-primary);
} transition: background 0.2s;
background: transparent;
.github-link:hover { border: 1px solid transparent;
background: #f2f3f5; cursor: pointer;
}
html.dark .github-link:hover {
background: #2a2a3c;
} }
.github-icon { .github-icon {
width: 20px; width: 18px;
height: 20px; height: 18px;
color: #606266;
transition: color 0.2s;
} }
.github-link:hover .github-icon { .github-link:hover {
color: #303133; background: var(--hover-bg);
} border-color: var(--header-border);
html.dark .github-icon {
color: #a0a3ad;
}
html.dark .github-link:hover .github-icon {
color: #e5e7eb;
} }
.theme-switch { .theme-switch {
--el-switch-on-color: #2c2c3a; --el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2; --el-switch-off-color: #f2f2f2;
--el-switch-border-color: #dcdfe6; --el-switch-border-color: var(--header-border);
}
html.dark .theme-switch {
--el-switch-off-color: #333;
} }
.theme-switch .el-switch__core .el-switch__inner .el-icon { .theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important; color: #909399 !important;
} }
.header-nav { .nav-bar {
position: relative; height: 48px;
padding: 0 32px; display: flex;
border-bottom: 1px solid #e4e7ed; align-items: center;
gap: 24px;
} }
html.dark .header-nav { .nav-link {
border-bottom-color: #3a3d5c; text-decoration: none;
}
.nav-menu {
background: transparent !important;
border-bottom: none !important;
height: 46px;
}
.nav-menu .el-menu-item,
.nav-menu .el-sub-menu__title {
position: relative;
height: 32px !important;
line-height: 32px !important;
border-bottom: none !important;
border-radius: 6px !important;
color: #666 !important;
font-weight: 400;
font-size: 14px; font-size: 14px;
padding: 0 12px !important; color: var(--text-secondary);
margin: 7px 0; padding: 8px 0;
transition: border-bottom: 2px solid transparent;
background 0.15s ease, transition: all 0.2s;
color 0.15s ease;
} }
.nav-menu > .el-menu-item, .nav-link:hover {
.nav-menu > .el-sub-menu { color: var(--text-primary);
margin-right: 4px;
} }
.nav-menu > .el-sub-menu { .nav-link.active {
padding: 0 !important; color: var(--active-link);
} border-bottom-color: var(--active-link);
html.dark .nav-menu .el-menu-item,
html.dark .nav-menu .el-sub-menu__title {
color: #888 !important;
}
.nav-menu .el-menu-item:hover,
.nav-menu .el-sub-menu__title:hover {
background: #f2f2f2 !important;
color: #171717 !important;
}
html.dark .nav-menu .el-menu-item:hover,
html.dark .nav-menu .el-sub-menu__title:hover {
background: #2a2a3c !important;
color: #e5e7eb !important;
}
.nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #171717 !important;
font-weight: 500;
}
.nav-menu .el-menu-item.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: #171717;
border-radius: 1px;
}
.nav-menu .el-menu-item.is-active:hover {
background: #f2f2f2 !important;
}
html.dark .nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #e5e7eb !important;
font-weight: 500;
}
html.dark .nav-menu .el-menu-item.is-active::after {
background: #e5e7eb;
}
html.dark .nav-menu .el-menu-item.is-active:hover {
background: #2a2a3c !important;
} }
#content { #content {
flex: 1; flex: 1;
padding: 24px 40px;
max-width: 1400px;
margin: 0 auto;
width: 100%; width: 100%;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-top { .header-content {
padding: 0 16px; padding: 0 20px;
}
.header-nav {
padding: 0 16px;
} }
#content { #content {
padding: 16px; padding: 20px;
}
.brand a {
font-size: 18px;
} }
} }
</style> </style>

View File

@ -19,7 +19,11 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, { ...defaultOptions, ...options }) const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) { if (!response.ok) {
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`) throw new HTTPError(
response.status,
response.statusText,
`HTTP ${response.status}`,
)
} }
// Handle empty response (e.g. 204 No Content) // Handle empty response (e.g. 204 No Content)
@ -31,20 +35,22 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
} }
export const http = { export const http = {
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }), get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body) body: JSON.stringify(body),
}), }),
put: <T>(url: string, body?: any, options?: RequestInit) => put: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, { request<T>(url, {
...options, ...options,
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers }, headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body) body: JSON.stringify(body),
}), }),
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }), delete: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'DELETE' }),
} }

View File

@ -1,5 +1,9 @@
import { http } from './http' import { http } from './http'
import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy' import type {
GetProxyResponse,
ProxyStatsInfo,
TrafficResponse,
} from '../types/proxy'
export const getProxiesByType = (type: string) => { export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`) return http.get<GetProxyResponse>(`../api/proxy/${type}`)
@ -9,6 +13,10 @@ export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`) return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
} }
export const getProxyByName = (name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
}
export const getProxyTraffic = (name: string) => { export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`) return http.get<TrafficResponse>(`../api/traffic/${name}`)
} }

View File

@ -67,7 +67,7 @@
.el-page-header__title { .el-page-header__title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 500;
} }
/* Better form layouts */ /* Better form layouts */

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
<path
d="M37 28 V72
M37 28 H63
M37 50 H55"
fill="none"
stroke="#FFFFFF"
stroke-width="14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -1,65 +1,48 @@
<template> <template>
<el-card class="client-card" shadow="hover" :body-style="{ padding: '20px' }"> <div class="client-card" @click="viewDetail">
<div class="client-header"> <div class="card-icon-wrapper">
<div class="client-status"> <div
<span class="status-dot" :class="statusClass"></span> class="status-dot-large"
<span class="client-name">{{ client.displayName }}</span> :class="client.online ? 'online' : 'offline'"
></div>
</div> </div>
<el-tag :type="client.statusColor" size="small">
<div class="card-content">
<div class="card-header">
<span class="client-main-id">{{ client.displayName }}</span>
<span v-if="client.hostname" class="hostname-badge">{{
client.hostname
}}</span>
</div>
<div class="card-meta">
<div class="meta-group">
<span v-if="client.ip" class="meta-item">
<span class="meta-label">IP</span>
<span class="meta-value">{{ client.ip }}</span>
</span>
</div>
<span class="meta-item activity">
<el-icon class="activity-icon"><DataLine /></el-icon>
<span class="meta-value">{{
client.online ? client.lastConnectedAgo : client.disconnectedAgo
}}</span>
</span>
</div>
</div>
<div class="card-action">
<div class="status-badge" :class="client.online ? 'online' : 'offline'">
{{ client.online ? 'Online' : 'Offline' }} {{ client.online ? 'Online' : 'Offline' }}
</el-tag>
</div> </div>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
<div class="client-info">
<div class="info-row">
<el-icon class="info-icon"><Monitor /></el-icon>
<span class="info-label">Hostname:</span>
<span class="info-value">{{ client.hostname || 'N/A' }}</span>
</div>
<div class="info-row" v-if="client.ip">
<el-icon class="info-icon"><Connection /></el-icon>
<span class="info-label">IP:</span>
<span class="info-value monospace">{{ client.ip }}</span>
</div>
<div class="info-row" v-if="client.user">
<el-icon class="info-icon"><User /></el-icon>
<span class="info-label">User:</span>
<span class="info-value">{{ client.user }}</span>
</div>
<div class="info-row">
<el-icon class="info-icon"><Key /></el-icon>
<span class="info-label">Run ID:</span>
<span class="info-value monospace">{{ client.runID }}</span>
</div>
<div class="info-row" v-if="client.firstConnectedAt">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">First Connected:</span>
<span class="info-value">{{ client.firstConnectedAgo }}</span>
</div>
<div class="info-row" v-if="client.online">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">Last Connected:</span>
<span class="info-value">{{ client.lastConnectedAgo }}</span>
</div>
<div class="info-row" v-if="!client.online && client.disconnectedAt">
<el-icon class="info-icon"><CircleClose /></el-icon>
<span class="info-label">Disconnected:</span>
<span class="info-value">{{ client.disconnectedAgo }}</span>
</div> </div>
</div> </div>
</el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { useRouter } from 'vue-router'
import { Monitor, User, Key, Clock, CircleClose, Connection } from '@element-plus/icons-vue' import { DataLine, ArrowRight } from '@element-plus/icons-vue'
import type { Client } from '../utils/client' import type { Client } from '../utils/client'
interface Props { interface Props {
@ -67,124 +50,209 @@ interface Props {
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const router = useRouter()
const statusClass = computed(() => { const viewDetail = () => {
return `status-${props.client.statusColor}` router.push({
name: 'ClientDetail',
params: { key: props.client.key },
}) })
}
</script> </script>
<style scoped> <style scoped>
.client-card { .client-card {
border-radius: 12px; display: flex;
transition: all 0.3s ease; align-items: center;
border: 1px solid #e4e7ed; gap: 20px;
padding: 24px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
} }
.client-card:hover { .client-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
border-color: var(--el-border-color-light);
} }
html.dark .client-card { .card-icon-wrapper {
border-color: #3a3d5c; width: 32px;
background: #27293d; height: 32px;
} border-radius: 8px;
background: var(--el-fill-color);
.client-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e4e7ed;
}
html.dark .client-header {
border-bottom-color: #3a3d5c;
}
.client-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: center;
flex-shrink: 0;
transition: all 0.2s;
} }
.status-dot { .client-card:hover .card-icon-wrapper {
width: 10px; background: var(--el-color-success-light-9);
height: 10px; }
.status-dot-large {
width: 6px;
height: 6px;
border-radius: 50%; border-radius: 50%;
transition: all 0.3s;
} }
.status-success { .status-dot-large.online {
background-color: #67c23a; background-color: var(--el-color-success);
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7); box-shadow: 0 0 0 2px var(--el-color-success-light-8);
} }
.status-warning { .status-dot-large.offline {
background-color: #e6a23c; background-color: var(--el-text-color-placeholder);
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
} }
.status-danger { .card-content {
background-color: #f56c6c; flex: 1;
box-shadow: 0 0 0 0 rgba(245, 108, 108, 0.7);
}
.client-name {
font-size: 16px;
font-weight: 600;
color: #303133;
}
html.dark .client-name {
color: #e5e7eb;
}
.client-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 8px;
margin-bottom: 16px; min-width: 0;
} }
.info-row { .card-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.client-main-id {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.2;
}
.hostname-badge {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: 6px;
background: var(--el-fill-color-dark);
color: var(--el-text-color-regular);
}
.card-meta {
display: flex;
align-items: center;
gap: 24px;
font-size: 13px;
color: var(--el-text-color-regular);
flex-wrap: wrap;
}
.meta-group {
display: flex;
align-items: center;
gap: 16px;
}
.meta-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-weight: 500;
font-size: 13px; font-size: 13px;
} }
.info-icon { .meta-value {
color: #909399; font-size: 13px;
font-size: 16px;
}
html.dark .info-icon {
color: #9ca3af;
}
.info-label {
color: #909399;
font-weight: 500; font-weight: 500;
min-width: 100px; color: var(--el-text-color-primary);
} }
html.dark .info-label { .activity .meta-value {
color: #9ca3af; font-weight: 400;
color: var(--el-text-color-secondary);
} }
.info-value { .card-action {
color: #606266; display: flex;
flex: 1; align-items: center;
gap: 20px;
flex-shrink: 0;
} }
html.dark .info-value { .status-badge {
color: #d1d5db; padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
} }
.monospace { .status-badge.online {
font-family: 'Courier New', Courier, monospace; background: var(--el-color-success-light-9);
font-size: 12px; color: var(--el-color-success);
word-break: break-all; }
.status-badge.offline {
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.arrow-icon {
font-size: 18px;
color: var(--el-text-color-placeholder);
transition: all 0.2s;
}
.client-card:hover .arrow-icon {
color: var(--el-text-color-primary);
transform: translateX(4px);
}
/* Dark mode adjustments */
html.dark .card-icon-wrapper {
background: var(--el-fill-color-light);
}
html.dark .client-card:hover .card-icon-wrapper {
background: var(--el-color-success-light-9);
}
html.dark .status-dot-large.online {
box-shadow: 0 0 0 2px rgba(var(--el-color-success-rgb), 0.2);
}
@media (max-width: 640px) {
.client-card {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.card-icon-wrapper {
width: 48px;
height: 48px;
}
.card-content {
width: 100%;
gap: 12px;
}
.card-action {
width: 100%;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--el-border-color-lighter);
}
} }
</style> </style>

View File

@ -0,0 +1,244 @@
<template>
<router-link :to="proxyLink" class="proxy-card">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span v-if="showType" class="type-tag">{{
proxy.type.toUpperCase()
}}</span>
</div>
<div class="card-meta">
<span v-if="proxy.port" class="meta-item">
<span class="meta-label">Port:</span>
<span class="meta-value">{{ proxy.port }}</span>
</span>
<span class="meta-item">
<span class="meta-label">Connections:</span>
<span class="meta-value">{{ proxy.conns }}</span>
</span>
<span class="meta-item" v-if="proxy.clientID">
<span class="meta-label">Client:</span>
<span class="meta-value">{{
proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID
}}</span>
</span>
</div>
</div>
<div class="card-right">
<div class="traffic-stats">
<div class="traffic-row">
<el-icon class="traffic-icon out"><Top /></el-icon>
<span class="traffic-value">{{
formatFileSize(proxy.trafficOut)
}}</span>
</div>
<div class="traffic-row">
<el-icon class="traffic-icon in"><Bottom /></el-icon>
<span class="traffic-value">{{
formatFileSize(proxy.trafficIn)
}}</span>
</div>
</div>
<div class="status-badge" :class="proxy.status">
{{ proxy.status }}
</div>
</div>
</div>
</router-link>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Top, Bottom } from '@element-plus/icons-vue'
import { formatFileSize } from '../utils/format'
import type { BaseProxy } from '../utils/proxy'
interface Props {
proxy: BaseProxy
showType?: boolean
}
const props = defineProps<Props>()
const route = useRoute()
const proxyLink = computed(() => {
const base = `/proxy/${props.proxy.name}`
// If we're on a client detail page, pass client info
if (route.name === 'ClientDetail' && route.params.key) {
return `${base}?from=client&client=${route.params.key}`
}
return base
})
</script>
<style scoped>
.proxy-card {
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.2s ease-in-out;
overflow: hidden;
text-decoration: none;
cursor: pointer;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
}
/* Left Section */
.card-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.proxy-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.type-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.card-meta {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-regular);
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 24px;
flex-shrink: 0;
}
.traffic-stats {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-end;
}
.traffic-row {
display: flex;
align-items: center;
gap: 6px;
line-height: 1;
}
.traffic-icon {
font-size: 12px;
}
.traffic-icon.in {
color: var(--el-color-primary);
}
.traffic-icon.out {
color: var(--el-color-success);
}
.traffic-value {
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 500;
text-align: right;
}
.status-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.online {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.offline {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.traffic-stats {
align-items: flex-start;
}
}
</style>

View File

@ -1,112 +0,0 @@
<template>
<el-form
label-position="left"
label-width="auto"
inline
class="proxy-table-expand"
>
<el-form-item label="Name">
<span>{{ row.name }}</span>
</el-form-item>
<el-form-item label="Type">
<span>{{ row.type }}</span>
</el-form-item>
<el-form-item label="Encryption">
<span>{{ row.encryption }}</span>
</el-form-item>
<el-form-item label="Compression">
<span>{{ row.compression }}</span>
</el-form-item>
<el-form-item label="Last Start">
<span>{{ row.lastStartTime }}</span>
</el-form-item>
<el-form-item label="Last Close">
<span>{{ row.lastCloseTime }}</span>
</el-form-item>
<div v-if="proxyType === 'http' || proxyType === 'https'">
<el-form-item label="Domains">
<span>{{ row.customDomains }}</span>
</el-form-item>
<el-form-item label="SubDomain">
<span>{{ row.subdomain }}</span>
</el-form-item>
<el-form-item label="locations">
<span>{{ row.locations }}</span>
</el-form-item>
<el-form-item label="HostRewrite">
<span>{{ row.hostHeaderRewrite }}</span>
</el-form-item>
</div>
<div v-else-if="proxyType === 'tcpmux'">
<el-form-item label="Multiplexer">
<span>{{ row.multiplexer }}</span>
</el-form-item>
<el-form-item label="RouteByHTTPUser">
<span>{{ row.routeByHTTPUser }}</span>
</el-form-item>
<el-form-item label="Domains">
<span>{{ row.customDomains }}</span>
</el-form-item>
<el-form-item label="SubDomain">
<span>{{ row.subdomain }}</span>
</el-form-item>
</div>
<div v-else>
<el-form-item label="Addr">
<span>{{ row.addr }}</span>
</el-form-item>
</div>
</el-form>
<div v-if="row.annotations && row.annotations.size > 0">
<el-divider />
<el-text class="title-text" size="large">Annotations</el-text>
<ul>
<li v-for="item in annotationsArray()" :key="item.key">
<span class="annotation-key">{{ item.key }}</span>
<span>{{ item.value }}</span>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
row: any
proxyType: string
}>()
// annotationsArray returns an array of key-value pairs from the annotations map.
const annotationsArray = (): Array<{ key: string; value: string }> => {
const array: Array<{ key: string; value: any }> = []
if (props.row.annotations) {
props.row.annotations.forEach((value: any, key: string) => {
array.push({ key, value })
})
}
return array
}
</script>
<style>
ul {
list-style-type: none;
padding: 5px;
}
ul li {
justify-content: space-between;
padding: 5px;
}
ul .annotation-key {
width: 300px;
display: inline-block;
vertical-align: middle;
}
.title-text {
color: #99a9bf;
}
</style>

View File

@ -167,7 +167,7 @@ html.dark .icon-traffic {
.stat-value { .stat-value {
font-size: 28px; font-size: 28px;
font-weight: 600; font-weight: 500;
line-height: 1.2; line-height: 1.2;
color: #303133; color: #303133;
margin-bottom: 4px; margin-bottom: 4px;

View File

@ -15,13 +15,19 @@
<div v-for="(item, index) in chartData" :key="index" class="day-column"> <div v-for="(item, index) in chartData" :key="index" class="day-column">
<div class="bars-group"> <div class="bars-group">
<el-tooltip :content="`In: ${formatFileSize(item.in)}`" placement="top"> <el-tooltip
:content="`In: ${formatFileSize(item.in)}`"
placement="top"
>
<div <div
class="bar bar-in" class="bar bar-in"
:style="{ height: Math.max(item.inPercent, 1) + '%' }" :style="{ height: Math.max(item.inPercent, 1) + '%' }"
></div> ></div>
</el-tooltip> </el-tooltip>
<el-tooltip :content="`Out: ${formatFileSize(item.out)}`" placement="top"> <el-tooltip
:content="`Out: ${formatFileSize(item.out)}`"
placement="top"
>
<div <div
class="bar bar-out" class="bar bar-out"
:style="{ height: Math.max(item.outPercent, 1) + '%' }" :style="{ height: Math.max(item.outPercent, 1) + '%' }"
@ -35,12 +41,8 @@
<!-- Legend --> <!-- Legend -->
<div v-if="!loading && chartData.length > 0" class="legend"> <div v-if="!loading && chartData.length > 0" class="legend">
<div class="legend-item"> <div class="legend-item"><span class="dot in"></span> Traffic In</div>
<span class="dot in"></span> Traffic In <div class="legend-item"><span class="dot out"></span> Traffic Out</div>
</div>
<div class="legend-item">
<span class="dot out"></span> Traffic Out
</div>
</div> </div>
<el-empty v-else-if="!loading" description="No traffic data" /> <el-empty v-else-if="!loading" description="No traffic data" />
@ -58,13 +60,15 @@ const props = defineProps<{
}>() }>()
const loading = ref(false) const loading = ref(false)
const chartData = ref<Array<{ const chartData = ref<
Array<{
date: string date: string
in: number in: number
out: number out: number
inPercent: number inPercent: number
outPercent: number outPercent: number
}>>([]) }>
>([])
const maxVal = ref(0) const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => { const processData = (trafficIn: number[], trafficOut: number[]) => {
@ -179,9 +183,16 @@ html.dark .grid-line {
background-color: #3a3d5c; background-color: #3a3d5c;
} }
.grid-line.top { top: 0; } .grid-line.top {
.grid-line.middle { top: 50%; transform: translateY(-50%); } top: 0;
.grid-line.bottom { bottom: 24px; } /* Align with bottom of bars */ }
.grid-line.middle {
top: 50%;
transform: translateY(-50%);
}
.grid-line.bottom {
bottom: 24px;
} /* Align with bottom of bars */
.day-column { .day-column {
flex: 1; flex: 1;
@ -255,6 +266,10 @@ html.dark .legend-item {
border-radius: 50%; border-radius: 50%;
} }
.dot.in { background-color: #5470c6; } .dot.in {
.dot.out { background-color: #91cc75; } background-color: #5470c6;
}
.dot.out {
background-color: #91cc75;
}
</style> </style>

View File

@ -1,10 +1,15 @@
import { createRouter, createWebHashHistory } from 'vue-router' import { createRouter, createWebHashHistory } from 'vue-router'
import ServerOverview from '../views/ServerOverview.vue' import ServerOverview from '../views/ServerOverview.vue'
import Clients from '../views/Clients.vue' import Clients from '../views/Clients.vue'
import ClientDetail from '../views/ClientDetail.vue'
import Proxies from '../views/Proxies.vue' import Proxies from '../views/Proxies.vue'
import ProxyDetail from '../views/ProxyDetail.vue'
const router = createRouter({ const router = createRouter({
history: createWebHashHistory(), history: createWebHashHistory(),
scrollBehavior() {
return { top: 0 }
},
routes: [ routes: [
{ {
path: '/', path: '/',
@ -16,11 +21,21 @@ const router = createRouter({
name: 'Clients', name: 'Clients',
component: Clients, component: Clients,
}, },
{
path: '/clients/:key',
name: 'ClientDetail',
component: ClientDetail,
},
{ {
path: '/proxies/:type?', path: '/proxies/:type?',
name: 'Proxies', name: 'Proxies',
component: Proxies, component: Proxies,
}, },
{
path: '/proxy/:name',
name: 'ProxyDetail',
component: ProxyDetail,
},
], ],
}) })

View File

@ -1,6 +1,8 @@
export interface ProxyStatsInfo { export interface ProxyStatsInfo {
name: string name: string
conf: any conf: any
user: string
clientID: string
clientVersion: string clientVersion: string
todayTrafficIn: number todayTrafficIn: number
todayTrafficOut: number todayTrafficOut: number

View File

@ -10,6 +10,8 @@ class BaseProxy {
lastStartTime: string lastStartTime: string
lastCloseTime: string lastCloseTime: string
status: string status: string
user: string
clientID: string
clientVersion: string clientVersion: string
addr: string addr: string
port: number port: number
@ -19,6 +21,10 @@ class BaseProxy {
locations: string locations: string
subdomain: string subdomain: string
// TCPMux specific
multiplexer: string
routeByHTTPUser: string
constructor(proxyStats: any) { constructor(proxyStats: any) {
this.name = proxyStats.name this.name = proxyStats.name
this.type = '' this.type = ''
@ -41,6 +47,8 @@ class BaseProxy {
this.lastStartTime = proxyStats.lastStartTime this.lastStartTime = proxyStats.lastStartTime
this.lastCloseTime = proxyStats.lastCloseTime this.lastCloseTime = proxyStats.lastCloseTime
this.status = proxyStats.status this.status = proxyStats.status
this.user = proxyStats.user || ''
this.clientID = proxyStats.clientID || ''
this.clientVersion = proxyStats.clientVersion this.clientVersion = proxyStats.clientVersion
this.addr = '' this.addr = ''
@ -49,6 +57,8 @@ class BaseProxy {
this.hostHeaderRewrite = '' this.hostHeaderRewrite = ''
this.locations = '' this.locations = ''
this.subdomain = '' this.subdomain = ''
this.multiplexer = ''
this.routeByHTTPUser = ''
} }
} }
@ -111,20 +121,15 @@ class HTTPSProxy extends BaseProxy {
} }
class TCPMuxProxy extends BaseProxy { class TCPMuxProxy extends BaseProxy {
multiplexer: string
routeByHTTPUser: string
constructor(proxyStats: any, port: number, subdomainHost: string) { constructor(proxyStats: any, port: number, subdomainHost: string) {
super(proxyStats) super(proxyStats)
this.type = 'tcpmux' this.type = 'tcpmux'
this.port = port this.port = port
this.multiplexer = ''
this.routeByHTTPUser = ''
if (proxyStats.conf) { if (proxyStats.conf) {
this.customDomains = proxyStats.conf.customDomains || this.customDomains this.customDomains = proxyStats.conf.customDomains || this.customDomains
this.multiplexer = proxyStats.conf.multiplexer this.multiplexer = proxyStats.conf.multiplexer || ''
this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || ''
if (proxyStats.conf.subdomain) { if (proxyStats.conf.subdomain) {
this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}` this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`
} }

View File

@ -0,0 +1,522 @@
<template>
<div class="client-detail-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
<span class="breadcrumb-separator">/</span>
<span class="breadcrumb-current">{{
client?.displayName || route.params.key
}}</span>
</nav>
<div v-loading="loading" class="detail-content">
<template v-if="client">
<!-- Header Card -->
<div class="header-card">
<div class="header-main">
<div class="header-left">
<div class="client-avatar">
{{ client.displayName.charAt(0).toUpperCase() }}
</div>
<div class="client-info">
<h1 class="client-name">{{ client.displayName }}</h1>
<div class="client-meta">
<span v-if="client.ip" class="meta-item">{{
client.ip
}}</span>
<span v-if="client.hostname" class="meta-item">{{
client.hostname
}}</span>
</div>
</div>
</div>
<div class="header-right">
<span
class="status-badge"
:class="client.online ? 'online' : 'offline'"
>
{{ client.online ? 'Online' : 'Offline' }}
</span>
</div>
</div>
<!-- Info Section -->
<div class="info-section">
<div class="info-item">
<span class="info-label">Connections</span>
<span class="info-value">{{ totalConnections }}</span>
</div>
<div class="info-item">
<span class="info-label">Run ID</span>
<span class="info-value">{{ client.runID }}</span>
</div>
<div class="info-item">
<span class="info-label">First Connected</span>
<span class="info-value">{{ client.firstConnectedAgo }}</span>
</div>
<div class="info-item">
<span class="info-label">{{
client.online ? 'Connected' : 'Disconnected'
}}</span>
<span class="info-value">{{
client.online ? client.lastConnectedAgo : client.disconnectedAgo
}}</span>
</div>
</div>
</div>
<!-- Proxies Card -->
<div class="proxies-card">
<div class="proxies-header">
<div class="proxies-title">
<h2>Proxies</h2>
<span class="proxies-count">{{ filteredProxies.length }}</span>
</div>
<el-input
v-model="proxySearch"
placeholder="Search proxies..."
:prefix-icon="Search"
clearable
class="proxy-search"
/>
</div>
<div class="proxies-body">
<div v-if="proxiesLoading" class="loading-state">
<el-icon class="is-loading"><Loading /></el-icon>
<span>Loading...</span>
</div>
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
:proxy="proxy"
show-type
/>
</div>
<div v-else-if="clientProxies.length > 0" class="empty-state">
<p>No proxies match "{{ proxySearch }}"</p>
</div>
<div v-else class="empty-state">
<p>No proxies found</p>
</div>
</div>
</div>
</template>
<div v-else-if="!loading" class="not-found">
<h2>Client not found</h2>
<p>The client doesn't exist or has been removed.</p>
<router-link to="/clients">
<el-button type="primary">Back to Clients</el-button>
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import { getClient } from '../api/client'
import { getProxiesByType } from '../api/proxy'
import {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import { getServerInfo } from '../api/server'
import ProxyCard from '../components/ProxyCard.vue'
const route = useRoute()
const router = useRouter()
const client = ref<Client | null>(null)
const loading = ref(true)
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/clients')
}
}
const proxiesLoading = ref(false)
const allProxies = ref<BaseProxy[]>([])
const proxySearch = ref('')
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
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 () => {
const key = route.params.key as string
if (!key) {
loading.value = false
return
}
try {
const data = await getClient(key)
client.value = new Client(data)
} catch (error: any) {
ElMessage.error('Failed to fetch client: ' + error.message)
} finally {
loading.value = false
}
}
const fetchProxies = async () => {
proxiesLoading.value = true
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
const proxies: BaseProxy[] = []
try {
const info = await fetchServerInfo()
for (const type of proxyTypes) {
try {
const json = await getProxiesByType(type)
if (!json.proxies) continue
if (type === 'tcp') {
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
} else if (type === 'udp') {
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
} else if (type === 'http' && info?.vhostHTTPPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
),
)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
),
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxies.push(
...json.proxies.map(
(p: any) =>
new TCPMuxProxy(
p,
info.tcpmuxHTTPConnectPort,
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 {
// Ignore
}
}
allProxies.value = proxies
} catch {
// Ignore
} finally {
proxiesLoading.value = false
}
}
onMounted(() => {
fetchClient()
fetchProxies()
})
</script>
<style scoped>
.client-detail-page {
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--el-color-primary);
}
.breadcrumb-separator {
color: var(--el-border-color);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
}
/* Card Base */
.header-card,
.proxies-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
/* Header Card */
.header-main {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 24px;
}
.header-left {
display: flex;
gap: 16px;
align-items: center;
}
.client-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 500;
flex-shrink: 0;
}
.client-info {
min-width: 0;
}
.client-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 4px 0;
line-height: 1.3;
}
.client-meta {
display: flex;
gap: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.status-badge {
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.status-badge.offline {
background: var(--hover-bg);
color: var(--text-secondary);
}
html.dark .status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
/* Info Section */
.info-section {
display: flex;
flex-wrap: wrap;
gap: 16px 32px;
padding: 16px 24px;
}
.info-item {
display: flex;
align-items: baseline;
gap: 8px;
}
.info-label {
font-size: 13px;
color: var(--text-secondary);
}
.info-label::after {
content: ':';
}
.info-value {
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
word-break: break-all;
}
/* Proxies Card */
.proxies-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 16px;
}
.proxies-title {
display: flex;
align-items: center;
gap: 8px;
}
.proxies-title h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.proxies-count {
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 4px 10px;
border-radius: 6px;
}
.proxy-search {
width: 200px;
}
.proxy-search :deep(.el-input__wrapper) {
border-radius: 6px;
}
.proxies-body {
padding: 16px;
}
.proxies-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px;
color: var(--text-secondary);
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.empty-state p {
margin: 0;
}
/* Not Found */
.not-found {
text-align: center;
padding: 60px 20px;
}
.not-found h2 {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
}
.not-found p {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
/* Responsive */
@media (max-width: 640px) {
.header-main {
flex-direction: column;
gap: 16px;
}
.header-right {
align-self: flex-start;
}
}
</style>

View File

@ -1,35 +1,49 @@
<template> <template>
<div class="clients-page"> <div class="clients-page">
<div class="filter-bar"> <div class="page-header">
<div class="header-top">
<div class="title-section">
<h1 class="page-title">Clients</h1>
<p class="page-subtitle">Manage connected clients and their status</p>
</div>
<div class="status-tabs">
<button
v-for="tab in statusTabs"
:key="tab.value"
class="status-tab"
:class="{ active: statusFilter === tab.value }"
@click="statusFilter = tab.value"
>
<span class="status-dot" :class="tab.value"></span>
<span class="tab-label">{{ tab.label }}</span>
<span class="tab-count">{{ tab.count }}</span>
</button>
</div>
</div>
<div class="search-section">
<el-input <el-input
v-model="searchText" v-model="searchText"
placeholder="Search by hostname, user, client ID, run ID..." placeholder="Search clients..."
:prefix-icon="Search" :prefix-icon="Search"
clearable clearable
class="search-input" class="search-input"
/> />
<el-radio-group v-model="statusFilter" class="status-filter"> </div>
<el-radio-button label="all">All ({{ stats.total }})</el-radio-button>
<el-radio-button label="online">
Online ({{ stats.online }})
</el-radio-button>
<el-radio-button label="offline">
Offline ({{ stats.offline }})
</el-radio-button>
</el-radio-group>
</div> </div>
<div v-loading="loading" class="clients-grid"> <div v-loading="loading" class="clients-content">
<el-empty <div v-if="filteredClients.length > 0" class="clients-list">
v-if="filteredClients.length === 0 && !loading"
description="No clients found"
/>
<ClientCard <ClientCard
v-for="client in filteredClients" v-for="client in filteredClients"
:key="client.key" :key="client.key"
:client="client" :client="client"
/> />
</div> </div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No clients found" />
</div>
</div>
</div> </div>
</template> </template>
@ -55,6 +69,12 @@ const stats = computed(() => {
return { total, online, offline } return { total, online, offline }
}) })
const statusTabs = computed(() => [
{ value: 'all' as const, label: 'All', count: stats.value.total },
{ value: 'online' as const, label: 'Online', count: stats.value.online },
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
])
const filteredClients = computed(() => { const filteredClients = computed(() => {
let result = clients.value let result = clients.value
@ -87,7 +107,6 @@ const fetchData = async () => {
const json = await getClients() const json = await getClients()
clients.value = json.map((data) => new Client(data)) clients.value = json.map((data) => new Client(data))
} catch (error: any) { } catch (error: any) {
console.error('Failed to fetch clients:', error)
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch clients: ' + error.message, message: 'Failed to fetch clients: ' + error.message,
@ -99,7 +118,6 @@ const fetchData = async () => {
} }
const startAutoRefresh = () => { const startAutoRefresh = () => {
// Auto refresh every 5 seconds
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
fetchData() fetchData()
}, 5000) }, 5000)
@ -124,46 +142,161 @@ onUnmounted(() => {
<style scoped> <style scoped>
.clients-page { .clients-page {
padding: 0 20px 20px 20px; display: flex;
flex-direction: column;
gap: 24px;
} }
.filter-bar { .page-header {
display: flex; display: flex;
gap: 16px; flex-direction: column;
align-items: center; gap: 24px;
margin-bottom: 20px; }
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.search-input { .title-section {
flex: 1; display: flex;
min-width: 300px; flex-direction: column;
max-width: 500px; gap: 8px;
} }
.status-filter { .page-title {
flex-shrink: 0; font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
} }
.clients-grid { .page-subtitle {
display: grid; font-size: 14px;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); color: var(--el-text-color-secondary);
gap: 20px; margin: 0;
}
.status-tabs {
display: flex;
gap: 12px;
}
.status-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border: 1px solid var(--el-border-color);
border-radius: 20px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.status-tab:hover {
border-color: var(--el-border-color-darker);
background: var(--el-fill-color-light);
}
.status-tab.active {
background: var(--el-fill-color-dark);
border-color: var(--el-text-color-primary);
color: var(--el-text-color-primary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--el-text-color-secondary);
}
.status-dot.online {
background-color: var(--el-color-success);
}
.status-dot.offline {
background-color: var(--el-text-color-placeholder);
}
.status-dot.all {
background-color: var(--el-text-color-regular);
}
.tab-count {
font-weight: 500;
opacity: 0.8;
}
.search-section {
width: 100%;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 8px 16px;
border: 1px solid var(--el-border-color);
transition: all 0.2s;
height: 48px;
font-size: 15px;
}
.search-input :deep(.el-input__wrapper:hover) {
border-color: var(--el-border-color-darker);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.search-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary);
}
.clients-content {
min-height: 200px; min-height: 200px;
} }
@media (max-width: 768px) { .clients-list {
.clients-grid { display: flex;
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column; flex-direction: column;
align-items: stretch; gap: 16px;
} }
.search-input { .empty-state {
max-width: none; padding: 60px 0;
}
/* Dark mode adjustments */
html.dark .status-tab {
background: var(--el-bg-color-overlay);
}
html.dark .status-tab.active {
background: var(--el-fill-color);
}
@media (max-width: 640px) {
.header-top {
flex-direction: column;
align-items: flex-start;
}
.status-tabs {
width: 100%;
overflow-x: auto;
padding-bottom: 4px;
}
.status-tab {
flex-shrink: 0;
} }
} }
</style> </style>

View File

@ -1,34 +1,26 @@
<template> <template>
<div class="proxies-page"> <div class="proxies-page">
<!-- Main Content --> <div class="page-header">
<el-card class="main-card" shadow="never"> <div class="header-top">
<div class="toolbar-header"> <div class="title-section">
<el-tabs v-model="activeType" class="proxy-tabs"> <h1 class="page-title">Proxies</h1>
<el-tab-pane <p class="page-subtitle">View and manage all proxy configurations</p>
v-for="t in proxyTypes" </div>
:key="t.value"
:label="t.label" <div class="actions-section">
:name="t.value" <el-button :icon="Refresh" class="action-btn" @click="fetchData"
/> >Refresh</el-button
</el-tabs> >
<div class="toolbar-actions">
<el-input
v-model="searchText"
placeholder="Search by name..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-popconfirm <el-popconfirm
title="Are you sure to clear all data of offline proxies?" title="Clear all offline proxies?"
width="220"
confirm-button-text="Clear"
cancel-button-text="Cancel"
@confirm="clearOfflineProxies" @confirm="clearOfflineProxies"
> >
<template #reference> <template #reference>
<el-button type="danger" plain :icon="Delete" <el-button :icon="Delete" class="action-btn" type="danger" plain
>Clear Offline</el-button >Clear Offline</el-button
> >
</template> </template>
@ -36,118 +28,74 @@
</div> </div>
</div> </div>
<el-table <div class="filter-section">
v-loading="loading" <div class="search-row">
:data="filteredProxies" <el-input
:default-sort="{ prop: 'name', order: 'ascending' }" v-model="searchText"
style="width: 100%" placeholder="Search proxies..."
> :prefix-icon="Search"
<el-table-column type="expand"> clearable
<template #default="props"> class="main-search"
<div class="expand-wrapper">
<ProxyViewExpand :row="props.row" :proxyType="activeType" />
</div>
</template>
</el-table-column>
<el-table-column
label="Name"
prop="name"
sortable
min-width="150"
show-overflow-tooltip
/> />
<el-table-column label="Port" prop="port" sortable width="100" />
<el-table-column
label="Conns"
prop="conns"
sortable
width="100"
align="center"
/>
<el-table-column label="Traffic" width="220">
<template #default="scope">
<div class="traffic-cell">
<span class="traffic-item up" title="Traffic Out">
<el-icon><Top /></el-icon>
{{ formatFileSize(scope.row.trafficOut) }}
</span>
<span class="traffic-item down" title="Traffic In">
<el-icon><Bottom /></el-icon>
{{ formatFileSize(scope.row.trafficIn) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column
label="Version"
prop="clientVersion"
sortable
width="140"
show-overflow-tooltip
/>
<el-table-column
label="Status"
prop="status"
sortable
width="120"
align="center"
>
<template #default="scope">
<el-tag
:type="scope.row.status === 'online' ? 'success' : 'danger'"
effect="light"
round
>
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="Action"
width="120"
align="center"
fixed="right"
>
<template #default="scope">
<el-button
type="primary"
link
:icon="DataAnalysis"
@click="showTraffic(scope.row.name)"
>
Traffic
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog <el-select
v-model="dialogVisible" :model-value="selectedClientKey"
destroy-on-close placeholder="All Clients"
:title="`Traffic Statistics - ${dialogVisibleName}`" clearable
width="700px" filterable
align-center class="client-select"
class="traffic-dialog" @change="onClientFilterChange"
> >
<Traffic :proxyName="dialogVisibleName" /> <el-option label="All Clients" value="" />
</el-dialog> <el-option
v-if="clientIDFilter && !selectedClientInList"
:label="`${userFilter ? userFilter + '.' : ''}${clientIDFilter} (not found)`"
:value="selectedClientKey"
style="color: var(--el-color-warning); font-style: italic"
/>
<el-option
v-for="client in clientOptions"
:key="client.key"
:label="client.label"
:value="client.key"
/>
</el-select>
</div>
<div class="type-tabs">
<button
v-for="t in proxyTypes"
:key="t.value"
class="type-tab"
:class="{ active: activeType === t.value }"
@click="activeType = t.value"
>
{{ t.label }}
</button>
</div>
</div>
</div>
<div v-loading="loading" class="proxies-content">
<div v-if="filteredProxies.length > 0" class="proxies-list">
<ProxyCard
v-for="proxy in filteredProxies"
:key="proxy.name"
:proxy="proxy"
/>
</div>
<div v-else-if="!loading" class="empty-state">
<el-empty description="No proxies found" />
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { formatFileSize } from '../utils/format'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import { Search, Refresh, Delete } from '@element-plus/icons-vue'
Search,
Refresh,
Delete,
Top,
Bottom,
DataAnalysis,
} from '@element-plus/icons-vue'
import { import {
BaseProxy, BaseProxy,
TCPProxy, TCPProxy,
@ -158,10 +106,14 @@ import {
STCPProxy, STCPProxy,
SUDPProxy, SUDPProxy,
} from '../utils/proxy' } from '../utils/proxy'
import ProxyViewExpand from '../components/ProxyViewExpand.vue' import ProxyCard from '../components/ProxyCard.vue'
import Traffic from '../components/Traffic.vue' import {
import { getProxiesByType, clearOfflineProxies as apiClearOfflineProxies } from '../api/proxy' getProxiesByType,
clearOfflineProxies as apiClearOfflineProxies,
} from '../api/proxy'
import { getServerInfo } from '../api/server' import { getServerInfo } from '../api/server'
import { getClients } from '../api/client'
import { Client } from '../utils/client'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@ -178,19 +130,85 @@ const proxyTypes = [
const activeType = ref((route.params.type as string) || 'tcp') const activeType = ref((route.params.type as string) || 'tcp')
const proxies = ref<BaseProxy[]>([]) const proxies = ref<BaseProxy[]>([])
const clients = ref<Client[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const dialogVisible = ref(false) const clientIDFilter = ref((route.query.clientID as string) || '')
const dialogVisibleName = ref('') const userFilter = ref((route.query.user as string) || '')
const clientOptions = computed(() => {
return clients.value
.map((c) => ({
key: c.key,
clientID: c.clientID,
user: c.user,
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
}))
.sort((a, b) => a.label.localeCompare(b.label))
})
// Compute selected client key for el-select v-model
const selectedClientKey = computed(() => {
if (!clientIDFilter.value) return ''
const client = clientOptions.value.find(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
// Return a synthetic key even if not found, so the select shows the filter is active
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
})
// Check if the filtered client exists in the client list
const selectedClientInList = computed(() => {
if (!clientIDFilter.value) return true
return clientOptions.value.some(
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
)
})
const filteredProxies = computed(() => { const filteredProxies = computed(() => {
if (!searchText.value) { let result = proxies.value
return proxies.value
// Filter by clientID and user if specified
if (clientIDFilter.value) {
result = result.filter(
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
)
} }
// Filter by search text
if (searchText.value) {
const search = searchText.value.toLowerCase() const search = searchText.value.toLowerCase()
return proxies.value.filter((p) => p.name.toLowerCase().includes(search)) result = result.filter((p) => p.name.toLowerCase().includes(search))
}
return result
}) })
const onClientFilterChange = (key: string) => {
if (key) {
const client = clientOptions.value.find((c) => c.key === key)
if (client) {
router.replace({
query: { ...route.query, clientID: client.clientID, user: client.user },
})
}
} else {
const query = { ...route.query }
delete query.clientID
delete query.user
router.replace({ query })
}
}
const fetchClients = async () => {
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch {
// Ignore errors when fetching clients
}
}
// Server info cache // Server info cache
let serverInfo: { let serverInfo: {
vhostHTTPPort: number vhostHTTPPort: number
@ -247,7 +265,6 @@ const fetchData = async () => {
proxies.value = json.proxies.map((p: any) => new SUDPProxy(p)) proxies.value = json.proxies.map((p: any) => new SUDPProxy(p))
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to fetch proxies:', error)
ElMessage({ ElMessage({
showClose: true, showClose: true,
message: 'Failed to fetch proxies: ' + error.message, message: 'Failed to fetch proxies: ' + error.message,
@ -258,11 +275,6 @@ const fetchData = async () => {
} }
} }
const showTraffic = (name: string) => {
dialogVisibleName.value = name
dialogVisible.value = true
}
const clearOfflineProxies = async () => { const clearOfflineProxies = async () => {
try { try {
await apiClearOfflineProxies() await apiClearOfflineProxies()
@ -281,95 +293,177 @@ const clearOfflineProxies = async () => {
// Watch for type changes // Watch for type changes
watch(activeType, (newType) => { watch(activeType, (newType) => {
router.replace({ params: { type: newType } }) // Update route but preserve query params
router.replace({ params: { type: newType }, query: route.query })
fetchData() fetchData()
}) })
// Watch for route query changes (client filter)
watch(
() => [route.query.clientID, route.query.user],
([newClientID, newUser]) => {
clientIDFilter.value = (newClientID as string) || ''
userFilter.value = (newUser as string) || ''
},
)
// Initial fetch // Initial fetch
fetchData() fetchData()
fetchClients()
</script> </script>
<style scoped> <style scoped>
.proxies-page { .proxies-page {
padding: 24px; display: flex;
max-width: 1600px; flex-direction: column;
margin: 0 auto; gap: 24px;
} }
/* Main Content */ .page-header {
.main-card { display: flex;
border-radius: 12px; flex-direction: column;
border: none; gap: 24px;
} }
.toolbar-header { .header-top {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 20px; gap: 20px;
flex-wrap: wrap;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
} }
.proxy-tabs :deep(.el-tabs__header) { .title-section {
margin-bottom: 0; display: flex;
flex-direction: column;
gap: 8px;
} }
.proxy-tabs :deep(.el-tabs__nav-wrap::after) { .page-title {
height: 0; font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
} }
.toolbar-actions { .page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.actions-section {
display: flex; display: flex;
gap: 12px; gap: 12px;
}
.action-btn {
border-radius: 8px;
padding: 8px 16px;
height: 36px;
font-weight: 500;
}
.filter-section {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 8px;
}
.search-row {
display: flex;
gap: 16px;
width: 100%;
align-items: center; align-items: center;
} }
.search-input { .main-search {
flex: 1;
}
.main-search,
.client-select {
height: 44px;
}
.main-search :deep(.el-input__wrapper),
.client-select :deep(.el-input__wrapper) {
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 16px;
height: 100%;
border: 1px solid var(--el-border-color);
}
.main-search :deep(.el-input__wrapper) {
font-size: 15px;
}
.client-select {
width: 240px; width: 240px;
} }
/* Table Styling */ .client-select :deep(.el-select__wrapper) {
.traffic-cell { border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
padding: 0 12px;
height: 44px;
min-height: 44px;
border: 1px solid var(--el-border-color);
}
.type-tabs {
display: flex; display: flex;
flex-direction: column; gap: 8px;
gap: 4px; overflow-x: auto;
padding-bottom: 4px;
}
.type-tab {
padding: 6px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
background: var(--el-bg-color);
color: var(--el-text-color-regular);
font-size: 13px; font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
} }
.traffic-item { .type-tab:hover {
background: var(--el-fill-color-light);
}
.type-tab.active {
background: var(--el-fill-color-darker);
color: var(--el-text-color-primary);
border-color: var(--el-fill-color-darker);
}
.proxies-content {
min-height: 200px;
}
.proxies-list {
display: flex; display: flex;
align-items: center;
gap: 4px;
}
.traffic-item.up {
color: #67c23a;
}
.traffic-item.down {
color: #409eff;
}
.expand-wrapper {
padding: 16px 24px;
background-color: transparent;
}
/* Responsive */
@media (max-width: 768px) {
.toolbar-header {
flex-direction: column; flex-direction: column;
align-items: stretch; gap: 16px;
} }
.toolbar-actions { .empty-state {
justify-content: space-between; padding: 60px 0;
} }
.search-input { @media (max-width: 768px) {
flex: 1; .search-row {
flex-direction: column;
}
.client-select {
width: 100%;
} }
} }
</style> </style>

View File

@ -0,0 +1,985 @@
<template>
<div class="proxy-detail-page">
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a class="breadcrumb-link" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</a>
<template v-if="fromClient">
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
<span class="breadcrumb-separator">/</span>
<router-link :to="`/clients/${fromClient}`" class="breadcrumb-item">{{
fromClient
}}</router-link>
<span class="breadcrumb-separator">/</span>
</template>
<template v-else>
<router-link to="/proxies" class="breadcrumb-item">Proxies</router-link>
<span class="breadcrumb-separator">/</span>
<router-link
v-if="proxy?.clientID"
:to="clientLink"
class="breadcrumb-item"
>
{{ proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID }}
</router-link>
<span v-if="proxy?.clientID" class="breadcrumb-separator">/</span>
</template>
<span class="breadcrumb-current">{{ proxyName }}</span>
</nav>
<div v-loading="loading" class="detail-content">
<template v-if="proxy">
<!-- Header Section -->
<div class="header-section">
<div class="header-main">
<div
class="proxy-icon"
:style="{ background: proxyIconConfig.gradient }"
>
<el-icon><component :is="proxyIconConfig.icon" /></el-icon>
</div>
<div class="header-info">
<div class="header-title-row">
<h1 class="proxy-name">{{ proxy.name }}</h1>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
<span class="status-badge" :class="proxy.status">
{{ proxy.status }}
</span>
</div>
<div class="header-meta">
<router-link
v-if="proxy.clientID"
:to="clientLink"
class="client-link"
>
<el-icon><Monitor /></el-icon>
<span
>Client:
{{
proxy.user
? `${proxy.user}.${proxy.clientID}`
: proxy.clientID
}}</span
>
</router-link>
</div>
</div>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-grid">
<div v-if="proxy.port" class="stat-card">
<div class="stat-header">
<span class="stat-label">Port</span>
<div class="stat-icon port">
<el-icon><Connection /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.port }}</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Connections</span>
<div class="stat-icon connections">
<el-icon><DataLine /></el-icon>
</div>
</div>
<div class="stat-value">{{ proxy.conns }}</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic In</span>
<div class="stat-icon traffic-in">
<el-icon><Bottom /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficIn)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficIn)
}}</span>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<span class="stat-label">Traffic Out</span>
<div class="stat-icon traffic-out">
<el-icon><Top /></el-icon>
</div>
</div>
<div class="stat-value">
<span class="value-number">{{
formatTrafficValue(proxy.trafficOut)
}}</span>
<span class="value-unit">{{
formatTrafficUnit(proxy.trafficOut)
}}</span>
</div>
</div>
</div>
<!-- Status Timeline -->
<div class="timeline-card">
<div class="timeline-header">
<el-icon><DataLine /></el-icon>
<h2>Status Timeline</h2>
</div>
<div class="timeline-body">
<div class="timeline-grid">
<div class="timeline-item">
<span class="timeline-label">Last Start Time</span>
<span class="timeline-value">{{
proxy.lastStartTime || '-'
}}</span>
</div>
<div class="timeline-item">
<span class="timeline-label">Last Close Time</span>
<span class="timeline-value">{{
proxy.lastCloseTime || '-'
}}</span>
</div>
</div>
</div>
</div>
<!-- Configuration Section -->
<div class="config-section">
<div class="config-section-header">
<el-icon><Setting /></el-icon>
<h2>Configuration</h2>
</div>
<!-- Config Cards Grid -->
<div class="config-grid">
<div class="config-item-card">
<div class="config-item-icon encryption">
<el-icon><Lock /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Encryption</span>
<span class="config-item-value">{{
proxy.encryption ? 'Enabled' : 'Disabled'
}}</span>
</div>
</div>
<div class="config-item-card">
<div class="config-item-icon compression">
<el-icon><Lightning /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Compression</span>
<span class="config-item-value">{{
proxy.compression ? 'Enabled' : 'Disabled'
}}</span>
</div>
</div>
<div v-if="proxy.customDomains" class="config-item-card">
<div class="config-item-icon domains">
<el-icon><Link /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Custom Domains</span>
<span class="config-item-value">{{ proxy.customDomains }}</span>
</div>
</div>
<div v-if="proxy.subdomain" class="config-item-card">
<div class="config-item-icon subdomain">
<el-icon><Link /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Subdomain</span>
<span class="config-item-value">{{ proxy.subdomain }}</span>
</div>
</div>
<div v-if="proxy.locations" class="config-item-card">
<div class="config-item-icon locations">
<el-icon><Location /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Locations</span>
<span class="config-item-value">{{ proxy.locations }}</span>
</div>
</div>
<div v-if="proxy.hostHeaderRewrite" class="config-item-card">
<div class="config-item-icon host">
<el-icon><Tickets /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Host Rewrite</span>
<span class="config-item-value">{{
proxy.hostHeaderRewrite
}}</span>
</div>
</div>
<div v-if="proxy.multiplexer" class="config-item-card">
<div class="config-item-icon multiplexer">
<el-icon><Cpu /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Multiplexer</span>
<span class="config-item-value">{{ proxy.multiplexer }}</span>
</div>
</div>
<div v-if="proxy.routeByHTTPUser" class="config-item-card">
<div class="config-item-icon route">
<el-icon><Connection /></el-icon>
</div>
<div class="config-item-content">
<span class="config-item-label">Route By HTTP User</span>
<span class="config-item-value">{{
proxy.routeByHTTPUser
}}</span>
</div>
</div>
</div>
<!-- Annotations -->
<template v-if="proxy.annotations && proxy.annotations.size > 0">
<div class="annotations-section">
<div
v-for="[key, value] in proxy.annotations"
:key="key"
class="annotation-tag"
>
{{ key }}: {{ value }}
</div>
</div>
</template>
</div>
<!-- Traffic Card -->
<div class="traffic-card">
<div class="traffic-header">
<h2>Traffic Statistics</h2>
</div>
<div class="traffic-body">
<Traffic :proxy-name="proxyName" />
</div>
</div>
</template>
<div v-else-if="!loading" class="not-found">
<h2>Proxy not found</h2>
<p>The proxy doesn't exist or has been removed.</p>
<router-link to="/proxies">
<el-button type="primary">Back to Proxies</el-button>
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowLeft,
Monitor,
Connection,
DataLine,
Bottom,
Top,
Link,
Lock,
Promotion,
Grid,
Setting,
Cpu,
Lightning,
Tickets,
Location,
} from '@element-plus/icons-vue'
import { getProxyByName } from '../api/proxy'
import { getServerInfo } from '../api/server'
import {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import Traffic from '../components/Traffic.vue'
const route = useRoute()
const router = useRouter()
const proxyName = computed(() => route.params.name as string)
const fromClient = computed(() => {
if (route.query.from === 'client' && route.query.client) {
return route.query.client as string
}
return null
})
const proxy = ref<BaseProxy | null>(null)
const loading = ref(true)
const goBack = () => {
if (window.history.length > 1) {
router.back()
} else {
router.push('/proxies')
}
}
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const clientLink = computed(() => {
if (!proxy.value) return ''
const key = proxy.value.user
? `${proxy.value.user}.${proxy.value.clientID}`
: proxy.value.clientID
return `/clients/${key}`
})
const proxyIconConfig = computed(() => {
const type = proxy.value?.type?.toLowerCase() || ''
const configs: Record<string, { icon: any; gradient: string }> = {
tcp: {
icon: Connection,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
},
udp: {
icon: Promotion,
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',
},
http: {
icon: Link,
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
},
https: {
icon: Lock,
gradient: 'linear-gradient(135deg, #14b8a6 0%, #0d9488 100%)',
},
stcp: {
icon: Lock,
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
},
sudp: {
icon: Lock,
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
},
tcpmux: {
icon: Grid,
gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
},
xtcp: {
icon: Connection,
gradient: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
},
}
return (
configs[type] || {
icon: Connection,
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
}
)
})
const formatTrafficValue = (bytes: number): string => {
if (bytes === 0) return '0'
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
const value = bytes / Math.pow(k, i)
return value < 10 ? value.toFixed(1) : Math.round(value).toString()
}
const formatTrafficUnit = (bytes: number): string => {
if (bytes === 0) return 'B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const k = 1024
const i = Math.floor(Math.log(bytes) / Math.log(k))
return units[i]
}
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchProxy = async () => {
const name = proxyName.value
if (!name) {
loading.value = false
return
}
try {
const data = await getProxyByName(name)
const info = await fetchServerInfo()
const type = data.conf?.type || ''
if (type === 'tcp') {
proxy.value = new TCPProxy(data)
} else if (type === 'udp') {
proxy.value = new UDPProxy(data)
} else if (type === 'http' && info?.vhostHTTPPort) {
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
} else if (type === 'https' && info?.vhostHTTPSPort) {
proxy.value = new HTTPSProxy(
data,
info.vhostHTTPSPort,
info.subdomainHost,
)
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
proxy.value = new TCPMuxProxy(
data,
info.tcpmuxHTTPConnectPort,
info.subdomainHost,
)
} else if (type === 'stcp') {
proxy.value = new STCPProxy(data)
} else if (type === 'sudp') {
proxy.value = new SUDPProxy(data)
} else {
proxy.value = new BaseProxy(data)
proxy.value.type = type
}
} catch (error: any) {
ElMessage.error('Failed to fetch proxy: ' + error.message)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchProxy()
})
</script>
<style scoped>
.proxy-detail-page {
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
margin-bottom: 24px;
}
.breadcrumb-link {
display: flex;
align-items: center;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s;
margin-right: 4px;
}
.breadcrumb-link:hover {
color: var(--text-primary);
}
.breadcrumb-item {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-item:hover {
color: var(--el-color-primary);
}
.breadcrumb-separator {
color: var(--el-border-color);
}
.breadcrumb-current {
color: var(--text-primary);
font-weight: 500;
}
/* Header Section */
.header-section {
margin-bottom: 24px;
}
.header-main {
display: flex;
align-items: flex-start;
gap: 16px;
}
.proxy-icon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 26px;
color: white;
}
.header-info {
flex: 1;
min-width: 0;
}
.header-title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.proxy-name {
font-size: 20px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
line-height: 1.3;
word-break: break-all;
}
.type-tag {
font-size: 12px;
font-weight: 500;
padding: 4px 12px;
border-radius: 20px;
background: var(--el-fill-color-dark);
color: var(--el-text-color-secondary);
border: 1px solid var(--el-border-color-lighter);
}
.status-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.online {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.status-badge.offline {
background: var(--hover-bg);
color: var(--text-secondary);
}
html.dark .status-badge.online {
background: rgba(34, 197, 94, 0.15);
color: #4ade80;
}
.header-meta {
display: flex;
align-items: center;
gap: 16px;
}
.client-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--text-secondary);
text-decoration: none;
transition: color 0.2s;
}
.client-link:hover {
color: var(--el-color-primary);
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
padding: 20px;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.stat-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.stat-icon.port {
background: rgba(139, 92, 246, 0.1);
color: #8b5cf6;
}
.stat-icon.connections {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
html.dark .stat-icon.port {
background: rgba(139, 92, 246, 0.15);
}
html.dark .stat-icon.connections {
background: rgba(168, 85, 247, 0.15);
}
html.dark .stat-icon.traffic-in {
background: rgba(59, 130, 246, 0.15);
}
html.dark .stat-icon.traffic-out {
background: rgba(34, 197, 94, 0.15);
}
.stat-value {
display: flex;
align-items: baseline;
gap: 6px;
}
.value-number {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
line-height: 1;
}
.stat-value:not(:has(.value-number)) {
font-size: 28px;
font-weight: 500;
color: var(--text-primary);
}
.value-unit {
font-size: 14px;
font-weight: 500;
color: var(--text-secondary);
}
/* Timeline Card */
.timeline-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
.timeline-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 20px;
color: var(--text-secondary);
}
.timeline-header h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.timeline-body {
padding: 20px;
padding-top: 0;
}
.timeline-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
background: var(--el-fill-color-light);
border-radius: 10px;
padding: 20px 24px;
}
.timeline-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.timeline-value {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
/* Card Base */
.traffic-card {
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
margin-bottom: 16px;
}
/* Config Section */
.config-section {
margin-bottom: 24px;
}
.config-section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: var(--text-secondary);
}
.config-section-header h2 {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
.config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.config-item-card {
display: flex;
align-items: flex-start;
gap: 14px;
padding: 20px;
background: var(--el-bg-color);
border: 1px solid var(--header-border);
border-radius: 12px;
}
.config-item-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.config-item-icon.encryption {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.config-item-icon.compression {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.config-item-icon.domains {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.config-item-icon.subdomain {
background: rgba(168, 85, 247, 0.1);
color: #a855f7;
}
.config-item-icon.locations {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.config-item-icon.host {
background: rgba(249, 115, 22, 0.1);
color: #f97316;
}
.config-item-icon.multiplexer {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.config-item-icon.route {
background: rgba(236, 72, 153, 0.1);
color: #ec4899;
}
html.dark .config-item-icon.encryption,
html.dark .config-item-icon.compression {
background: rgba(34, 197, 94, 0.15);
}
html.dark .config-item-icon.domains,
html.dark .config-item-icon.subdomain {
background: rgba(168, 85, 247, 0.15);
}
html.dark .config-item-icon.locations,
html.dark .config-item-icon.multiplexer {
background: rgba(59, 130, 246, 0.15);
}
html.dark .config-item-icon.host {
background: rgba(249, 115, 22, 0.15);
}
html.dark .config-item-icon.route {
background: rgba(236, 72, 153, 0.15);
}
.config-item-content {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.config-item-label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.config-item-value {
font-size: 15px;
color: var(--text-primary);
font-weight: 500;
word-break: break-all;
}
.annotations-section {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.annotation-tag {
display: inline-flex;
padding: 6px 12px;
background: var(--el-fill-color);
border-radius: 6px;
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}
.traffic-header {
padding: 16px 20px;
border-bottom: 1px solid var(--header-border);
}
.traffic-header h2 {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
margin: 0;
}
/* Traffic Card */
.traffic-body {
padding: 20px;
}
/* Not Found */
.not-found {
text-align: center;
padding: 60px 20px;
}
.not-found h2 {
font-size: 18px;
font-weight: 500;
color: var(--text-primary);
margin: 0 0 8px;
}
.not-found p {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 20px;
}
/* Responsive */
@media (max-width: 1024px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.header-main {
flex-direction: column;
gap: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.timeline-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -53,7 +53,9 @@
</div> </div>
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Inbound</div> <div class="label">Inbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div> <div class="value">
{{ formatFileSize(data.totalTrafficIn) }}
</div>
</div> </div>
</div> </div>
<div class="traffic-divider"></div> <div class="traffic-divider"></div>
@ -63,7 +65,9 @@
</div> </div>
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Outbound</div> <div class="label">Outbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div> <div class="value">
{{ formatFileSize(data.totalTrafficOut) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@ -187,7 +191,7 @@ const data = ref({
}) })
const hasActiveProxies = computed(() => { const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some(c => c > 0) return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
}) })
const formatTrafficTotal = () => { const formatTrafficTotal = () => {
@ -223,7 +227,7 @@ const fetchData = async () => {
data.value.proxyCounts = 0 data.value.proxyCounts = 0
if (json.proxyTypeCount != null) { if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => { Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += (count || 0) data.value.proxyCounts += count || 0
}) })
} }
} catch (err) { } catch (err) {
@ -283,7 +287,7 @@ html.dark .config-card {
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 600; font-weight: 500;
color: #303133; color: #303133;
} }
@ -337,7 +341,7 @@ html.dark .card-title {
.traffic-info .value { .traffic-info .value {
font-size: 24px; font-size: 24px;
font-weight: 600; font-weight: 500;
color: #303133; color: #303133;
} }
@ -386,7 +390,7 @@ html.dark .proxy-type-item {
.proxy-type-count { .proxy-type-count {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 500;
color: #303133; color: #303133;
} }
@ -437,7 +441,7 @@ html.dark .config-label {
.config-value { .config-value {
font-size: 14px; font-size: 14px;
color: #303133; color: #303133;
font-weight: 600; font-weight: 500;
word-break: break-all; word-break: break-all;
} }

View File

@ -39,7 +39,9 @@ export default defineConfig({
}, },
}, },
server: { server: {
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [], allowedHosts: process.env.ALLOWED_HOSTS
? process.env.ALLOWED_HOSTS.split(',')
: [],
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_API_URL || 'http://127.0.0.1:7500', target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',