Compare commits

..

1 Commits

Author SHA1 Message Date
fatedier
b94a0caea6 refactor: move web embeds to web/ directory 2026-01-27 02:49:20 +08:00
50 changed files with 1379 additions and 4555 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 frp web dashboard with a modern UI, dark mode support, and improved navigation. * Redesigned the frps 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, user string, clientID string) { func (m *serverMetrics) NewProxy(name string, proxyType string) {
for _, v := range m.ms { for _, v := range m.ms {
v.NewProxy(name, proxyType, user, clientID) v.NewProxy(name, proxyType)
} }
} }

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, user string, clientID string) { func (m *serverMetrics) NewProxy(name string, proxyType 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,8 +119,6 @@ func (m *serverMetrics) NewProxy(name string, proxyType string, user string, cli
} }
m.info.ProxyStatistics[name] = proxyStats m.info.ProxyStatistics[name] = proxyStats
} }
proxyStats.User = user
proxyStats.ClientID = clientID
proxyStats.LastStartTime = time.Now() proxyStats.LastStartTime = time.Now()
} }
@@ -216,8 +214,6 @@ 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()),
@@ -249,8 +245,6 @@ 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()),
@@ -266,31 +260,6 @@ 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,8 +35,6 @@ 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
@@ -53,8 +51,6 @@ 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
@@ -82,7 +78,6 @@ 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, _ string, _ string) { func (m *serverMetrics) NewProxy(name string, proxyType 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

@@ -14,7 +14,7 @@
package version package version
var version = "0.67.0" var version = "0.66.0"
func Full() string { func Full() string {
return version return version

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,48 +204,6 @@ 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")
@@ -261,10 +219,7 @@ 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 {
@@ -277,9 +232,9 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
continue continue
} }
proxyInfo.Status = "online" proxyInfo.Status = "online"
c.fillProxyClientInfo(&proxyClientInfo{ if pxy.GetLoginMsg() != nil {
clientVersion: &proxyInfo.ClientVersion, proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
}, pxy) }
} else { } else {
proxyInfo.Status = "offline" proxyInfo.Status = "offline"
} }
@@ -301,8 +256,6 @@ 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 {
@@ -337,7 +290,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,
@@ -351,37 +304,6 @@ 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,8 +98,6 @@ 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"`
@@ -117,9 +115,6 @@ 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,11 +405,7 @@ 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)
clientID := ctl.loginMsg.ClientID metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
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, user string, clientID string) NewProxy(name string, proxyType 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)
@@ -27,11 +27,11 @@ func Register(m ServerMetrics) {
type noopServerMetrics struct{} type noopServerMetrics struct{}
func (noopServerMetrics) NewClient() {} func (noopServerMetrics) NewClient() {}
func (noopServerMetrics) CloseClient() {} func (noopServerMetrics) CloseClient() {}
func (noopServerMetrics) NewProxy(string, string, string, string) {} func (noopServerMetrics) NewProxy(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) {}
func (noopServerMetrics) AddTrafficIn(string, string, int64) {} func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
func (noopServerMetrics) AddTrafficOut(string, string, int64) {} func (noopServerMetrics) AddTrafficOut(string, string, int64) {}

View File

@@ -24,7 +24,7 @@ import (
type ClientInfo struct { type ClientInfo struct {
Key string Key string
User string User string
RawClientID string ClientID 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}" (runID fallback when raw clientID is empty). // ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (or runID if clientID is empty).
// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records. // Entries without an explicit 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, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) { func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
if runID == "" { if runID == "" {
return "", false return "", false
} }
effectiveID := rawClientID effectiveID := clientID
if effectiveID == "" { if effectiveID == "" {
effectiveID = runID effectiveID = runID
} }
key = cr.composeClientKey(user, effectiveID) key = cr.composeClientKey(user, effectiveID)
enforceUnique := rawClientID != "" enforceUnique := clientID != ""
now := time.Now() now := time.Now()
cr.mu.Lock() cr.mu.Lock()
@@ -75,6 +75,7 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd
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
@@ -82,7 +83,6 @@ func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAdd
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.RawClientID == "" { if info.ClientID == "" {
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} with runID fallback). // GetByKey retrieves a client by its composite key ({user}.{clientID} or 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,30 +143,6 @@ 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 (runID fallback when raw clientID is empty). // Track logical clients keyed by user.clientID.
clientRegistry *registry.ClientRegistry clientRegistry *registry.ClientRegistry
// Manage all proxies // Manage all proxies
@@ -709,7 +709,6 @@ 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

@@ -9,18 +9,16 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
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']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElRow: typeof import('element-plus/es')['ElRow'] ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
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']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
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']
} }
export interface ComponentCustomProperties { export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@@ -1,50 +1,42 @@
<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('/')">frpc</a>
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge client-badge">Client</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
</div> </div>
<div class="header-actions">
<nav class="nav-bar"> <a
<router-link to="/" class="nav-link" active-class="active" class="github-link"
>Overview</router-link href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
> >
<router-link to="/configure" class="nav-link" active-class="active" <GitHubIcon class="github-icon" />
>Configure</router-link </a>
> <el-switch
</nav> v-model="darkmodeSwitch"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
@change="toggleDark"
class="theme-switch"
/>
</div>
</div> </div>
<nav class="header-nav">
<el-menu
:default-active="currentRoute"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
class="nav-menu"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/configure">Configure</el-menu-item>
</el-menu>
</nav>
</header> </header>
<main id="content"> <main id="content">
<router-view></router-view> <router-view></router-view>
</main> </main>
@@ -52,214 +44,252 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useDark } from '@vueuse/core' import { useDark, useToggle } 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 currentRouteName = computed(() => { const currentRoute = computed(() => {
if (route.path === '/') return 'Overview' return route.path
if (route.path === '/configure') return 'Configure'
return ''
}) })
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, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', -apple-system,
Arial, sans-serif; BlinkMacSystemFont,
Helvetica Neue,
sans-serif;
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--el-bg-color-page); background: #f2f2f2;
}
html.dark #app {
background: #1a1a2e;
} }
.header { .header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background: var(--header-bg); background: #fff;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
} }
.header-content { html.dark .header {
max-width: 1200px; background: #1e1e2d;
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-section { .brand a {
display: flex; color: #303133;
align-items: center; font-size: 20px;
gap: 12px; font-weight: 700;
} 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;
} }
.badge { html.dark .brand a {
font-size: 12px; color: #e5e7eb;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
} }
.badge.client-badge { .brand a:hover {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #409eff;
color: white;
border: none;
font-weight: 500;
} }
html.dark .badge.client-badge { .header-actions {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
.header-controls {
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;
justify-content: center; padding: 6px;
border-radius: 50%; border-radius: 6px;
color: var(--text-primary); transition: all 0.2s;
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
width: 18px;
height: 18px;
} }
.github-link:hover { .github-link:hover {
background: var(--hover-bg); background: #f2f3f5;
border-color: var(--header-border); }
html.dark .github-link:hover {
background: #2a2a3c;
}
.github-icon {
width: 20px;
height: 20px;
color: #606266;
transition: color 0.2s;
}
.github-link:hover .github-icon {
color: #303133;
}
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: var(--header-border); --el-switch-border-color: #dcdfe6;
}
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;
} }
.nav-bar { .header-nav {
height: 48px; position: relative;
display: flex; padding: 0 32px;
align-items: center; border-bottom: 1px solid #e4e7ed;
gap: 24px;
} }
.nav-link { html.dark .header-nav {
text-decoration: none; border-bottom-color: #3a3d5c;
}
.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;
color: var(--text-secondary); padding: 0 12px !important;
padding: 8px 0; margin: 7px 0;
border-bottom: 2px solid transparent; transition:
transition: all 0.2s; background 0.15s ease,
color 0.15s ease;
} }
.nav-link:hover { .nav-menu > .el-menu-item,
color: var(--text-primary); .nav-menu > .el-sub-menu {
margin-right: 4px;
} }
.nav-link.active { .nav-menu > .el-sub-menu {
color: var(--active-link); padding: 0 !important;
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;
width: 100%; padding: 24px 40px;
padding: 40px; max-width: 1400px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { .header-top {
padding: 0 20px; padding: 0 16px;
}
.header-nav {
padding: 0 16px;
} }
#content { #content {
padding: 20px; padding: 16px;
}
.brand a {
font-size: 18px;
} }
} }
</style> </style>

View File

@@ -19,11 +19,7 @@ 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( throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
response.status,
response.statusText,
`HTTP ${response.status}`,
)
} }
// Handle empty response (e.g. 204 No Content) // Handle empty response (e.g. 204 No Content)
@@ -39,54 +35,42 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
} }
export const http = { export const http = {
get: <T>(url: string, options?: RequestInit) => get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => { post: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers } const headers: HeadersInit = { ...options?.headers }
let requestBody = body let requestBody = body
if ( if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
body && if (!('Content-Type' in headers)) {
typeof body === 'object' && (headers as any)['Content-Type'] = 'application/json'
!(body instanceof FormData) && }
!(body instanceof Blob) requestBody = JSON.stringify(body)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
} }
return request<T>(url, { return request<T>(url, {
...options, ...options,
method: 'POST', method: 'POST',
headers, headers,
body: requestBody, body: requestBody
}) })
}, },
put: <T>(url: string, body?: any, options?: RequestInit) => { put: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers } const headers: HeadersInit = { ...options?.headers }
let requestBody = body let requestBody = body
if ( if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
body && if (!('Content-Type' in headers)) {
typeof body === 'object' && (headers as any)['Content-Type'] = 'application/json'
!(body instanceof FormData) && }
!(body instanceof Blob) requestBody = JSON.stringify(body)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
} }
return request<T>(url, { return request<T>(url, {
...options, ...options,
method: 'PUT', method: 'PUT',
headers, headers,
body: requestBody, body: requestBody
}) })
}, },
delete: <T>(url: string, options?: RequestInit) => delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
request<T>(url, { ...options, method: 'DELETE' }), }
}

View File

@@ -1,9 +1,32 @@
/* Modern Base Styles */ .el-form-item span {
margin-left: 15px;
}
.proxy-table-expand {
font-size: 0;
}
.proxy-table-expand .el-form-item__label{
width: 90px;
color: #99a9bf;
}
.proxy-table-expand .el-form-item {
margin-right: 0;
margin-bottom: 0;
width: 50%;
}
.el-table .el-table__expanded-cell {
padding: 20px 50px;
}
/* Modern styles */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
/* Smooth transitions for Element Plus components */ /* Smooth transitions */
.el-button, .el-button,
.el-card, .el-card,
.el-input, .el-input,
@@ -14,7 +37,7 @@
/* Card hover effects */ /* Card hover effects */
.el-card:hover { .el-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
} }
/* Better scrollbar */ /* Better scrollbar */
@@ -37,6 +60,16 @@
background: #a8a8a8; background: #a8a8a8;
} }
/* Page headers */
.el-page-header {
padding: 16px 0;
}
.el-page-header__title {
font-size: 20px;
font-weight: 600;
}
/* Better form layouts */ /* Better form layouts */
.el-form-item { .el-form-item {
margin-bottom: 18px; margin-bottom: 18px;
@@ -54,52 +87,3 @@
padding-right: 10px !important; padding-right: 10px !important;
} }
} }
/* Input enhancements */
.el-input__wrapper {
transition: all 0.2s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
/* Button enhancements */
.el-button {
font-weight: 500;
}
/* Tag enhancements */
.el-tag {
font-weight: 500;
}
/* Card enhancements */
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-card__body {
padding: 20px;
}
/* Table enhancements */
.el-table {
font-size: 14px;
}
.el-table th {
font-weight: 600;
}
/* Empty state */
.el-empty__description {
margin-top: 16px;
font-size: 14px;
}
/* Loading state */
.el-loading-mask {
border-radius: 12px;
}

View File

@@ -1,14 +1,11 @@
/* Dark Mode Theme */
html.dark { html.dark {
--el-bg-color: #1e1e2e; --el-bg-color: #1e1e2e;
--el-bg-color-page: #1a1a2e;
--el-bg-color-overlay: #27293d;
--el-fill-color-blank: #1e1e2e; --el-fill-color-blank: #1e1e2e;
background-color: #1a1a2e; background-color: #1e1e2e;
} }
html.dark body { html.dark body {
background-color: #1a1a2e; background-color: #1e1e2e;
color: #e5e7eb; color: #e5e7eb;
} }
@@ -31,50 +28,23 @@ html.dark .el-card {
border-color: #3a3d5c; border-color: #3a3d5c;
} }
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
}
/* Dark mode inputs */ /* Dark mode inputs */
html.dark .el-input__wrapper { html.dark .el-input__wrapper {
background-color: #27293d; background-color: #27293d;
box-shadow: 0 0 0 1px #3a3d5c inset; border-color: #3a3d5c;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
} }
html.dark .el-input__inner { html.dark .el-input__inner {
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-input__inner::placeholder {
color: #6b7280;
}
/* Dark mode textarea */
html.dark .el-textarea__inner {
background-color: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-textarea__inner::placeholder {
color: #6b7280;
}
/* Dark mode table */ /* Dark mode table */
html.dark .el-table { html.dark .el-table {
background-color: #27293d; background-color: #27293d;
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-table th.el-table__cell { html.dark .el-table th {
background-color: #1e1e2e; background-color: #1e1e2e;
color: #e5e7eb; color: #e5e7eb;
} }
@@ -83,98 +53,6 @@ html.dark .el-table tr {
background-color: #27293d; background-color: #27293d;
} }
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell { html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: #1e1e2e; background-color: #1e1e2e;
} }
html.dark .el-table__row:hover > td.el-table__cell {
background-color: #2a2a3c !important;
}
/* Dark mode tags */
html.dark .el-tag--info {
background-color: #3a3d5c;
border-color: #3a3d5c;
color: #e5e7eb;
}
/* Dark mode buttons */
html.dark .el-button--default {
background-color: #27293d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background-color: #2a2a3c;
border-color: #4a4d6c;
color: #fff;
}
/* Dark mode select */
html.dark .el-select .el-input__wrapper {
background-color: #27293d;
}
html.dark .el-select-dropdown {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-select-dropdown__item {
color: #e5e7eb;
}
html.dark .el-select-dropdown__item:hover {
background-color: #2a2a3c;
}
/* Dark mode dialog */
html.dark .el-dialog {
background-color: #27293d;
}
html.dark .el-dialog__header {
border-bottom-color: #3a3d5c;
}
html.dark .el-dialog__title {
color: #e5e7eb;
}
html.dark .el-dialog__body {
color: #e5e7eb;
}
/* Dark mode message box */
html.dark .el-message-box {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-message-box__title {
color: #e5e7eb;
}
html.dark .el-message-box__message {
color: #e5e7eb;
}
/* Dark mode empty */
html.dark .el-empty__description {
color: #9ca3af;
}
/* Dark mode loading */
html.dark .el-loading-mask {
background-color: rgba(30, 30, 46, 0.9);
}
html.dark .el-loading-text {
color: #e5e7eb;
}
/* Dark mode tooltip */
html.dark .el-tooltip__trigger {
color: #e5e7eb;
}

View File

@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 448 B

View File

@@ -1,236 +0,0 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local:</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin:</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote:</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
</div>
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">{{ proxy.err }}</span>
</div>
<div class="status-badge" :class="statusClass">
{{ proxy.status }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Warning } from '@element-plus/icons-vue'
import type { ProxyStatus } from '../types/proxy'
interface Props {
proxy: ProxyStatus
}
const props = defineProps<Props>()
const statusClass = computed(() => {
switch (props.proxy.status) {
case 'running':
return 'running'
case 'error':
return 'error'
default:
return 'waiting'
}
})
</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;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.proxy-card.has-error {
border-color: var(--el-color-danger-light-5);
}
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
}
.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: 10px;
}
.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: 20px;
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);
}
.meta-value.code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.error-info {
display: flex;
align-items: center;
gap: 6px;
max-width: 200px;
}
.error-icon {
color: var(--el-color-danger);
font-size: 16px;
flex-shrink: 0;
}
.error-text {
font-size: 12px;
color: var(--el-color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
/* 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;
}
.error-info {
max-width: none;
flex: 1;
}
}
</style>

View File

@@ -1,202 +0,0 @@
<template>
<el-card
class="stat-card"
:class="{ clickable: !!to }"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<div class="stat-card-content">
<div class="stat-icon" :class="`icon-${type}`">
<component :is="iconComponent" class="icon" />
</div>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Connection,
CircleCheck,
Warning,
Setting,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'proxies' | 'running' | 'error' | 'config'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'proxies',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'proxies':
return Connection
case 'running':
return CircleCheck
case 'error':
return Warning
case 'config':
return Setting
default:
return Connection
}
})
const handleClick = () => {
if (props.to) {
router.push(props.to)
}
}
</script>
<style scoped>
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.stat-card.clickable:hover .arrow-icon {
transform: translateX(4px);
}
html.dark .stat-card {
border-color: #3a3d5c;
background: #27293d;
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.arrow-icon {
color: #909399;
font-size: 18px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
html.dark .arrow-icon {
color: #9ca3af;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon .icon {
width: 28px;
height: 28px;
}
.icon-proxies {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-running {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.icon-error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-config {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-running {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .icon-error {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-config {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
html.dark .stat-value {
color: #e5e7eb;
}
.stat-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
html.dark .stat-label {
color: #9ca3af;
}
.stat-subtitle {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
font-size: 12px;
color: #909399;
}
html.dark .stat-subtitle {
border-top-color: #3a3d5c;
color: #9ca3af;
}
</style>

View File

@@ -10,4 +10,4 @@ const app = createApp(App)
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View File

@@ -18,4 +18,4 @@ const router = createRouter({
], ],
}) })
export default router export default router

View File

@@ -1,12 +1,12 @@
export interface ProxyStatus { export interface ProxyStatus {
name: string name: string
type: string type: string
status: string status: string
err: string err: string
local_addr: string local_addr: string
plugin: string plugin: string
remote_addr: string remote_addr: string
[key: string]: any [key: string]: any
} }
export type StatusResponse = Record<string, ProxyStatus[]> export type StatusResponse = Record<string, ProxyStatus[]>

View File

@@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
// Prevent index out of bounds for extremely large numbers // Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1] const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i) const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit return parseFloat(val.toFixed(2)) + ' ' + unit
} }

View File

@@ -1,120 +1,33 @@
<template> <template>
<div class="configure-page"> <div class="configure-page">
<div class="page-header"> <el-card class="main-card" shadow="never">
<div class="title-section"> <div class="toolbar-header">
<h1 class="page-title">Configuration</h1> <h2 class="card-title">Client Configuration</h2>
<p class="page-subtitle"> <div class="toolbar-actions">
Edit and manage your frpc configuration file <el-tooltip content="Refresh" placement="top">
</p> <el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">Update</el-button>
</div>
</div> </div>
</div>
<el-row :gutter="20"> <div class="config-editor">
<el-col :xs="24" :lg="16"> <el-input
<el-card class="editor-card" shadow="hover"> type="textarea"
<template #header> :autosize="{ minRows: 10, maxRows: 30 }"
<div class="card-header"> v-model="configContent"
<div class="header-left"> placeholder="frpc configuration file content..."
<span class="card-title">Configuration Editor</span> class="code-input"
<el-tag size="small" type="success">TOML</el-tag> ></el-input>
</div> </div>
<div class="header-actions"> </el-card>
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">
Update & Reload
</el-button>
</div>
</div>
</template>
<div class="editor-wrapper">
<el-input
type="textarea"
:autosize="{ minRows: 20, maxRows: 40 }"
v-model="configContent"
placeholder="# frpc configuration file content...
[common]
server_addr = 127.0.0.1
server_port = 7000"
class="code-editor"
></el-input>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card class="help-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Quick Reference</span>
</div>
</template>
<div class="help-content">
<div class="help-section">
<h4 class="help-section-title">Common Settings</h4>
<div class="help-items">
<div class="help-item">
<code>serverAddr</code>
<span>Server address</span>
</div>
<div class="help-item">
<code>serverPort</code>
<span>Server port (default: 7000)</span>
</div>
<div class="help-item">
<code>auth.token</code>
<span>Authentication token</span>
</div>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Proxy Types</h4>
<div class="proxy-type-tags">
<el-tag type="primary" effect="plain">TCP</el-tag>
<el-tag type="success" effect="plain">UDP</el-tag>
<el-tag type="warning" effect="plain">HTTP</el-tag>
<el-tag type="danger" effect="plain">HTTPS</el-tag>
<el-tag type="info" effect="plain">STCP</el-tag>
<el-tag effect="plain">XTCP</el-tag>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Example Proxy</h4>
<pre class="code-example">
[[proxies]]
name = "web"
type = "http"
localPort = 80
customDomains = ["example.com"]</pre
>
</div>
<div class="help-section">
<a
href="https://github.com/fatedier/frp#configuration-files"
target="_blank"
class="docs-link"
>
<el-icon><Link /></el-icon>
View Full Documentation
</a>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Upload, Link } from '@element-plus/icons-vue' import { Refresh, Upload } from '@element-plus/icons-vue'
import { getConfig, putConfig, reloadConfig } from '../api/frpc' import { getConfig, putConfig, reloadConfig } from '../api/frpc'
const configContent = ref('') const configContent = ref('')
@@ -140,7 +53,7 @@ const handleUpload = () => {
confirmButtonText: 'Update', confirmButtonText: 'Update',
cancelButtonText: 'Cancel', cancelButtonText: 'Cancel',
type: 'warning', type: 'warning',
}, }
) )
.then(async () => { .then(async () => {
if (!configContent.value.trim()) { if (!configContent.value.trim()) {
@@ -167,7 +80,7 @@ const handleUpload = () => {
} }
}) })
.catch(() => { .catch(() => {
// cancelled // cancelled
}) })
} }
@@ -175,227 +88,28 @@ fetchData()
</script> </script>
<style scoped> <style scoped>
.configure-page { .main-card {
display: flex;
flex-direction: column;
gap: 24px;
}
.page-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.editor-card,
.help-card {
border-radius: 12px; border-radius: 12px;
border: 1px solid #e4e7ed; border: none;
} }
html.dark .editor-card, .toolbar-header {
html.dark .help-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
flex-wrap: wrap; margin-bottom: 20px;
gap: 12px; border-bottom: 1px solid var(--el-border-color-lighter);
} padding-bottom: 16px;
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
} }
.card-title { .card-title {
font-size: 16px; margin: 0;
font-weight: 500; font-size: 18px;
color: #303133; font-weight: 600;
} }
html.dark .card-title { .code-input {
color: #e5e7eb; font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 14px;
} }
</style>
.editor-wrapper {
position: relative;
}
.code-editor :deep(.el-textarea__inner) {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
resize: none;
}
html.dark .code-editor :deep(.el-textarea__inner) {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.code-editor :deep(.el-textarea__inner:focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
/* Help Card */
.help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.help-section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.help-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.help-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
html.dark .help-item {
background: #1e1e2d;
}
.help-item code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.help-item span {
color: var(--el-text-color-secondary);
flex: 1;
}
.proxy-type-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.code-example {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
margin: 0;
overflow-x: auto;
white-space: pre;
}
html.dark .code-example {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.docs-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--el-color-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: 12px 16px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
transition: all 0.2s;
}
.docs-link:hover {
background: var(--el-color-primary-light-8);
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: flex-end;
}
}
@media (max-width: 992px) {
.help-card {
margin-top: 20px;
}
}
</style>

View File

@@ -1,132 +1,92 @@
<template> <template>
<div class="overview-page"> <div class="overview-page">
<el-row :gutter="20" class="stats-row"> <el-card class="main-card" shadow="never">
<el-col :xs="24" :sm="12" :lg="6"> <div class="toolbar-header">
<StatCard <h2 class="card-title">Proxy Status</h2>
label="Total Proxies" <div class="toolbar-actions">
:value="stats.total" <el-input
type="proxies" v-model="searchText"
subtitle="Configured proxies" placeholder="Search..."
/> :prefix-icon="Search"
</el-col> clearable
<el-col :xs="24" :sm="12" :lg="6"> class="search-input"
<StatCard />
label="Running" <el-tooltip content="Refresh" placement="top">
:value="stats.running" <el-button :icon="Refresh" circle @click="fetchData" />
type="running" </el-tooltip>
subtitle="Active connections" </div>
/> </div>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Error"
:value="stats.error"
type="error"
subtitle="Failed proxies"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Configure"
value="Edit"
type="config"
subtitle="Manage settings"
to="/configure"
/>
</el-col>
</el-row>
<el-row :gutter="20" class="content-row"> <el-table
<el-col :xs="24" :lg="16"> v-loading="loading"
<el-card class="proxy-list-card" shadow="hover"> :data="filteredStatus"
<template #header> :default-sort="{ prop: 'name', order: 'ascending' }"
<div class="card-header"> stripe
<div class="header-left"> style="width: 100%"
<span class="card-title">Proxy Status</span> class="proxy-table"
<el-tag size="small" type="info" >
>{{ stats.total }} proxies</el-tag <el-table-column
> prop="name"
</div> label="Name"
<div class="header-actions"> sortable
<el-input min-width="120"
v-model="searchText" ></el-table-column>
placeholder="Search..." <el-table-column
:prefix-icon="Search" prop="type"
clearable label="Type"
class="search-input" width="100"
/> sortable
<el-tooltip content="Refresh" placement="top"> >
<el-button :icon="Refresh" circle @click="fetchData" /> <template #default="scope">
</el-tooltip> <span class="type-text">{{ scope.row.type }}</span>
</div>
</div>
</template> </template>
</el-table-column>
<div v-loading="loading" class="proxy-list-content"> <el-table-column
<div v-if="filteredStatus.length > 0" class="proxy-list"> prop="local_addr"
<ProxyCard label="Local Address"
v-for="proxy in filteredStatus" min-width="150"
:key="proxy.name" sortable
:proxy="proxy" show-overflow-tooltip
/> ></el-table-column>
</div> <el-table-column
<div v-else-if="!loading" class="empty-state"> prop="plugin"
<el-empty description="No proxies found" /> label="Plugin"
</div> width="120"
</div> sortable
</el-card> show-overflow-tooltip
</el-col> ></el-table-column>
<el-table-column
<el-col :xs="24" :lg="8"> prop="remote_addr"
<el-card class="types-card" shadow="hover"> label="Remote Address"
<template #header> min-width="150"
<div class="card-header"> sortable
<span class="card-title">Proxy Types</span> show-overflow-tooltip
<el-tag size="small" type="info">Distribution</el-tag> ></el-table-column>
</div> <el-table-column
</template> prop="status"
<div class="proxy-types-grid"> label="Status"
<div width="120"
v-for="(count, type) in proxyTypeCounts" sortable
:key="type" align="center"
class="proxy-type-item" >
v-show="count > 0" <template #default="scope">
<el-tag
:type="getStatusColor(scope.row.status)"
effect="light"
round
> >
<div class="proxy-type-name"> {{ scope.row.status }}
{{ String(type).toUpperCase() }} </el-tag>
</div>
<div class="proxy-type-count">{{ count }}</div>
</div>
<div v-if="!hasActiveProxies" class="no-data">No proxy data</div>
</div>
</el-card>
<el-card class="status-summary-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Status Summary</span>
</div>
</template> </template>
<div class="status-list"> </el-table-column>
<div class="status-item"> <el-table-column prop="err" label="Info" min-width="150" show-overflow-tooltip>
<div class="status-indicator running"></div> <template #default="scope">
<span class="status-name">Running</span> <span v-if="scope.row.err" class="error-text">{{ scope.row.err }}</span>
<span class="status-count">{{ stats.running }}</span> <span v-else>-</span>
</div> </template>
<div class="status-item"> </el-table-column>
<div class="status-indicator waiting"></div> </el-table>
<span class="status-name">Waiting</span> </el-card>
<span class="status-count">{{ stats.waiting }}</span>
</div>
<div class="status-item">
<div class="status-indicator error"></div>
<span class="status-name">Error</span>
<span class="status-count">{{ stats.error }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div> </div>
</template> </template>
@@ -136,33 +96,11 @@ import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getStatus } from '../api/frpc' import { getStatus } from '../api/frpc'
import type { ProxyStatus } from '../types/proxy' import type { ProxyStatus } from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
const status = ref<ProxyStatus[]>([]) const status = ref<ProxyStatus[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const stats = computed(() => {
const total = status.value.length
const running = status.value.filter((p) => p.status === 'running').length
const error = status.value.filter((p) => p.status === 'error').length
const waiting = total - running - error
return { total, running, error, waiting }
})
const proxyTypeCounts = computed(() => {
const counts: Record<string, number> = {}
status.value.forEach((p) => {
counts[p.type] = (counts[p.type] || 0) + 1
})
return counts
})
const hasActiveProxies = computed(() => {
return status.value.length > 0
})
const filteredStatus = computed(() => { const filteredStatus = computed(() => {
if (!searchText.value) { if (!searchText.value) {
return status.value return status.value
@@ -173,16 +111,28 @@ const filteredStatus = computed(() => {
p.name.toLowerCase().includes(search) || p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) || p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) || p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search), p.remote_addr.toLowerCase().includes(search)
) )
}) })
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'success'
case 'error':
return 'danger'
default:
return 'warning'
}
}
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
const json = await getStatus() const json = await getStatus()
status.value = [] status.value = []
for (const key in json) { for (const key in json) {
// json[key] is generic array, we assume it matches ProxyStatus
for (const ps of json[key]) { for (const ps of json[key]) {
status.value.push(ps) status.value.push(ps)
} }
@@ -203,240 +153,63 @@ fetchData()
<style scoped> <style scoped>
.overview-page { .overview-page {
display: flex; /* No special padding needed if App.vue handles content padding */
flex-direction: column;
gap: 20px;
} }
.stats-row { .main-card {
margin-bottom: 0;
}
.stats-row .el-col {
margin-bottom: 20px;
}
.content-row .el-col {
margin-bottom: 20px;
}
.proxy-list-card,
.types-card,
.status-summary-card {
border-radius: 12px; border-radius: 12px;
border: 1px solid #e4e7ed; border: none;
}
html.dark .proxy-list-card,
html.dark .types-card,
html.dark .status-summary-card {
border-color: #3a3d5c;
background: #27293d;
}
.status-summary-card {
margin-top: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
} }
.card-title { .card-title {
font-size: 16px; margin: 0;
font-weight: 500; font-size: 18px;
color: #303133; font-weight: 600;
} }
html.dark .card-title { .toolbar-header {
color: #e5e7eb; display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
} }
.search-input { .search-input {
width: 200px; width: 240px;
} }
.proxy-list-content { .error-text {
min-height: 200px; color: var(--el-color-danger);
} }
.proxy-list { .type-text {
display: flex; display: inline-block;
flex-direction: column; padding: 2px 8px;
gap: 12px; font-size: 12px;
} font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
.empty-state { border-radius: 4px;
padding: 40px 0;
}
/* Proxy Types Grid */
.proxy-types-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
min-height: 80px;
align-content: center;
}
.proxy-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.proxy-type-item:hover {
background: #f0f2f5;
transform: translateY(-2px);
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
html.dark .proxy-type-item:hover {
background: #2a2a3c;
}
.proxy-type-name {
font-size: 11px;
color: #909399;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
.proxy-type-count {
font-size: 20px;
font-weight: 500;
color: #303133;
}
html.dark .proxy-type-count {
color: #e5e7eb;
}
.no-data {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 80px;
color: #909399;
font-size: 14px;
}
/* Status Summary */
.status-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.status-item:hover {
background: #f0f2f5;
}
html.dark .status-item {
background: #1e1e2d;
}
html.dark .status-item:hover {
background: #2a2a3c;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.running {
background: var(--el-color-success);
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
}
.status-indicator.waiting {
background: var(--el-color-warning);
box-shadow: 0 0 0 3px var(--el-color-warning-light-8);
}
.status-indicator.error {
background: var(--el-color-danger);
box-shadow: 0 0 0 3px var(--el-color-danger-light-8);
}
.status-name {
flex: 1;
font-size: 14px;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
font-weight: 500;
}
.status-count {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.card-header { .toolbar-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: space-between;
}
.search-input { .search-input {
flex: 1; width: 100%;
width: auto;
}
.proxy-types-grid {
grid-template-columns: repeat(3, 1fr);
} }
} }
</style>
@media (max-width: 992px) {
.status-summary-card {
margin-top: 0;
}
}
</style>

View File

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

View File

@@ -11,17 +11,28 @@ 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']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
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']
ElOption: typeof import('element-plus/es')['ElOption'] ElMenu: typeof import('element-plus/es')['ElMenu']
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']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.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,6 +621,19 @@
"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",
@@ -640,9 +653,9 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "8.57.1", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
"integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -665,14 +678,13 @@
} }
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
"integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"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.3", "@humanwhocodes/object-schema": "^2.0.2",
"debug": "^4.3.1", "debug": "^4.3.1",
"minimatch": "^3.0.5" "minimatch": "^3.0.5"
}, },
@@ -695,10 +707,9 @@
} }
}, },
"node_modules/@humanwhocodes/object-schema": { "node_modules/@humanwhocodes/object-schema": {
"version": "2.0.3", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
"integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
"deprecated": "Use @eslint/object-schema instead",
"dev": true, "dev": true,
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
@@ -1571,9 +1582,9 @@
} }
}, },
"node_modules/@types/semver": { "node_modules/@types/semver": {
"version": "7.7.1", "version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", "integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1585,17 +1596,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.20.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", "integrity": "sha512-fTwGQUnjhoYHeSF6m5pWNkzmDDdsKELYrOBxhjMrofPqCkoC2k3B2wvGHFxa1CTIqkEn88nlW1HVMztjo2K8Hg==",
"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.21.0", "@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/type-utils": "6.21.0", "@typescript-eslint/type-utils": "6.20.0",
"@typescript-eslint/utils": "6.21.0", "@typescript-eslint/utils": "6.20.0",
"@typescript-eslint/visitor-keys": "6.21.0", "@typescript-eslint/visitor-keys": "6.20.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",
@@ -1621,16 +1632,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz",
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.20.0",
"@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/typescript-estree": "6.20.0",
"@typescript-eslint/visitor-keys": "6.21.0", "@typescript-eslint/visitor-keys": "6.20.0",
"debug": "^4.3.4" "debug": "^4.3.4"
}, },
"engines": { "engines": {
@@ -1650,14 +1661,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", "integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.21.0" "@typescript-eslint/visitor-keys": "6.20.0"
}, },
"engines": { "engines": {
"node": "^16.0.0 || >=18.0.0" "node": "^16.0.0 || >=18.0.0"
@@ -1668,14 +1679,14 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.20.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", "integrity": "sha512-qnSobiJQb1F5JjN0YDRPHruQTrX7ICsmltXhkV536mp4idGAYrIyr47zF/JmkJtEcAVnIz4gUYJ7gOZa6SmN4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/typescript-estree": "6.20.0",
"@typescript-eslint/utils": "6.21.0", "@typescript-eslint/utils": "6.20.0",
"debug": "^4.3.4", "debug": "^4.3.4",
"ts-api-utils": "^1.0.1" "ts-api-utils": "^1.0.1"
}, },
@@ -1696,9 +1707,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", "integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1710,14 +1721,14 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", "integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.21.0", "@typescript-eslint/visitor-keys": "6.20.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",
@@ -1755,18 +1766,18 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.20.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", "integrity": "sha512-/EKuw+kRu2vAqCoDwDCBtDRU6CTKbUmwwI7SH7AashZ+W+7o8eiyy6V2cdOqN49KsTcASWsC5QeghYuRDTyOOg==",
"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.21.0", "@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.20.0",
"@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/typescript-estree": "6.20.0",
"semver": "^7.5.4" "semver": "^7.5.4"
}, },
"engines": { "engines": {
@@ -1781,13 +1792,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0", "version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", "integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.20.0",
"eslint-visitor-keys": "^3.4.1" "eslint-visitor-keys": "^3.4.1"
}, },
"engines": { "engines": {
@@ -1798,6 +1809,19 @@
"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",
@@ -2471,9 +2495,9 @@
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "6.0.6", "version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
"integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2488,9 +2512,9 @@
} }
}, },
"node_modules/cross-spawn/node_modules/semver": { "node_modules/cross-spawn/node_modules/semver": {
"version": "5.7.2", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
@@ -3033,18 +3057,17 @@
} }
}, },
"node_modules/eslint": { "node_modules/eslint": {
"version": "8.57.1", "version": "8.56.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"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.57.1", "@eslint/js": "8.56.0",
"@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/config-array": "^0.11.13",
"@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",
@@ -3212,16 +3235,13 @@
} }
}, },
"node_modules/eslint-visitor-keys": { "node_modules/eslint-visitor-keys": {
"version": "3.4.3", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
"integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
"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": {
@@ -3288,9 +3308,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/eslint/node_modules/cross-spawn": { "node_modules/eslint/node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3332,6 +3352,19 @@
"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",
@@ -4400,9 +4433,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4510,15 +4543,15 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": { "node_modules/lodash-es": {
"version": "4.17.23", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-unified": { "node_modules/lodash-unified": {
@@ -4621,9 +4654,9 @@
} }
}, },
"node_modules/minimatch/node_modules/brace-expansion": { "node_modules/minimatch/node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -4738,9 +4771,9 @@
} }
}, },
"node_modules/normalize-package-data/node_modules/semver": { "node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.2", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {

View File

@@ -1,56 +1,43 @@
<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>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge server-badge">Server</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
</div> </div>
<div class="header-actions">
<nav class="nav-bar"> <a
<router-link to="/" class="nav-link" active-class="active" class="github-link"
>Overview</router-link href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
> >
<router-link to="/clients" class="nav-link" active-class="active" <GitHubIcon class="github-icon" />
>Clients</router-link </a>
> <el-switch
<router-link v-model="darkmodeSwitch"
to="/proxies" inline-prompt
class="nav-link" :active-icon="Moon"
:class="{ active: route.path.startsWith('/proxies') }" :inactive-icon="Sunny"
>Proxies</router-link @change="toggleDark"
> class="theme-switch"
</nav> />
</div>
</div> </div>
<nav class="header-nav">
<el-menu
:default-active="currentRoute"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
class="nav-menu"
>
<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>
</header> </header>
<main id="content"> <main id="content">
<router-view></router-view> <router-view></router-view>
</main> </main>
@@ -58,215 +45,256 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useDark } from '@vueuse/core' import { useDark, useToggle } 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 currentRouteName = computed(() => { const currentRoute = computed(() => {
if (route.path === '/') return 'Overview' // Normalize /proxies/:type to /proxies for menu highlighting
if (route.path.startsWith('/clients')) return 'Clients' if (route.path.startsWith('/proxies')) {
if (route.path.startsWith('/proxies')) return '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, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', -apple-system,
Arial, sans-serif; BlinkMacSystemFont,
Helvetica Neue,
sans-serif;
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--el-bg-color-page); background: #f2f2f2;
}
html.dark #app {
background: #1a1a2e;
} }
.header { .header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background: var(--header-bg); background: #fff;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
} }
.header-content { html.dark .header {
max-width: 1200px; background: #1e1e2d;
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-section { .brand a {
display: flex; color: #303133;
align-items: center; font-size: 20px;
gap: 12px; font-weight: 700;
} 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;
} }
.badge { html.dark .brand a {
font-size: 12px; color: #e5e7eb;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
} }
.badge.server-badge { .brand a:hover {
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%); color: #409eff;
color: white;
border: none;
font-weight: 500;
} }
html.dark .badge.server-badge { .header-actions {
background: linear-gradient(135deg, #60a5fa 0%, #22d3ee 100%);
}
.header-controls {
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;
justify-content: center; padding: 6px;
border-radius: 50%; border-radius: 6px;
color: var(--text-primary); transition: all 0.2s;
transition: background 0.2s;
background: transparent;
border: 1px solid transparent;
cursor: pointer;
}
.github-icon {
width: 18px;
height: 18px;
} }
.github-link:hover { .github-link:hover {
background: var(--hover-bg); background: #f2f3f5;
border-color: var(--header-border); }
html.dark .github-link:hover {
background: #2a2a3c;
}
.github-icon {
width: 20px;
height: 20px;
color: #606266;
transition: color 0.2s;
}
.github-link:hover .github-icon {
color: #303133;
}
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: var(--header-border); --el-switch-border-color: #dcdfe6;
}
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;
} }
.nav-bar { .header-nav {
height: 48px; position: relative;
display: flex; padding: 0 32px;
align-items: center; border-bottom: 1px solid #e4e7ed;
gap: 24px;
} }
.nav-link { html.dark .header-nav {
text-decoration: none; border-bottom-color: #3a3d5c;
}
.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;
color: var(--text-secondary); padding: 0 12px !important;
padding: 8px 0; margin: 7px 0;
border-bottom: 2px solid transparent; transition:
transition: all 0.2s; background 0.15s ease,
color 0.15s ease;
} }
.nav-link:hover { .nav-menu > .el-menu-item,
color: var(--text-primary); .nav-menu > .el-sub-menu {
margin-right: 4px;
} }
.nav-link.active { .nav-menu > .el-sub-menu {
color: var(--active-link); padding: 0 !important;
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;
width: 100%; padding: 24px 40px;
padding: 40px; max-width: 1400px;
max-width: 1200px;
margin: 0 auto; margin: 0 auto;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-content { .header-top {
padding: 0 20px; padding: 0 16px;
}
.header-nav {
padding: 0 16px;
} }
#content { #content {
padding: 20px; padding: 16px;
}
.brand a {
font-size: 18px;
} }
} }
</style> </style>

View File

@@ -19,11 +19,7 @@ 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( throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
response.status,
response.statusText,
`HTTP ${response.status}`,
)
} }
// Handle empty response (e.g. 204 No Content) // Handle empty response (e.g. 204 No Content)
@@ -35,22 +31,20 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
} }
export const http = { export const http = {
get: <T>(url: string, options?: RequestInit) => get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
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) => delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
request<T>(url, { ...options, method: 'DELETE' }),
} }

View File

@@ -1,9 +1,5 @@
import { http } from './http' import { http } from './http'
import type { import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy'
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}`)
@@ -13,10 +9,6 @@ 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: 500; font-weight: 600;
} }
/* Better form layouts */ /* Better form layouts */

View File

@@ -1,15 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 448 B

View File

@@ -1,48 +1,65 @@
<template> <template>
<div class="client-card" @click="viewDetail"> <el-card class="client-card" shadow="hover" :body-style="{ padding: '20px' }">
<div class="card-icon-wrapper"> <div class="client-header">
<div <div class="client-status">
class="status-dot-large" <span class="status-dot" :class="statusClass"></span>
:class="client.online ? 'online' : 'offline'" <span class="client-name">{{ client.displayName }}</span>
></div>
</div>
<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>
<el-tag :type="client.statusColor" size="small">
<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' }}
</div> </el-tag>
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
</div> </div>
</div>
<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>
</el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { computed } from 'vue'
import { DataLine, ArrowRight } from '@element-plus/icons-vue' import { Monitor, User, Key, Clock, CircleClose, Connection } from '@element-plus/icons-vue'
import type { Client } from '../utils/client' import type { Client } from '../utils/client'
interface Props { interface Props {
@@ -50,209 +67,124 @@ interface Props {
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const router = useRouter()
const viewDetail = () => { const statusClass = computed(() => {
router.push({ return `status-${props.client.statusColor}`
name: 'ClientDetail', })
params: { key: props.client.key },
})
}
</script> </script>
<style scoped> <style scoped>
.client-card { .client-card {
display: flex; border-radius: 12px;
align-items: center; transition: all 0.3s ease;
gap: 20px; border: 1px solid #e4e7ed;
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 24px rgba(0, 0, 0, 0.04); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
border-color: var(--el-border-color-light);
} }
.card-icon-wrapper { html.dark .client-card {
width: 32px; border-color: #3a3d5c;
height: 32px; background: #27293d;
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;
justify-content: center; gap: 8px;
flex-shrink: 0;
transition: all 0.2s;
} }
.client-card:hover .card-icon-wrapper { .status-dot {
background: var(--el-color-success-light-9); width: 10px;
} height: 10px;
.status-dot-large {
width: 6px;
height: 6px;
border-radius: 50%; border-radius: 50%;
transition: all 0.3s;
} }
.status-dot-large.online { .status-success {
background-color: var(--el-color-success); background-color: #67c23a;
box-shadow: 0 0 0 2px var(--el-color-success-light-8); box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7);
} }
.status-dot-large.offline { .status-warning {
background-color: var(--el-text-color-placeholder); background-color: #e6a23c;
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
} }
.card-content { .status-danger {
flex: 1; background-color: #f56c6c;
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: 8px; gap: 10px;
min-width: 0; margin-bottom: 16px;
} }
.card-header { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
flex-wrap: wrap; font-size: 13px;
} }
.client-main-id { .info-icon {
font-size: 15px; color: #909399;
font-weight: 600; font-size: 16px;
color: var(--el-text-color-primary);
line-height: 1.2;
} }
.hostname-badge { html.dark .info-icon {
color: #9ca3af;
}
.info-label {
color: #909399;
font-weight: 500;
min-width: 100px;
}
html.dark .info-label {
color: #9ca3af;
}
.info-value {
color: #606266;
flex: 1;
}
html.dark .info-value {
color: #d1d5db;
}
.monospace {
font-family: 'Courier New', Courier, monospace;
font-size: 12px; font-size: 12px;
font-weight: 500; word-break: break-all;
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;
align-items: center;
gap: 8px;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-weight: 500;
font-size: 13px;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-primary);
}
.activity .meta-value {
font-weight: 400;
color: var(--el-text-color-secondary);
}
.card-action {
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.status-badge {
padding: 4px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
}
.status-badge.online {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.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

@@ -1,244 +0,0 @@
<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

@@ -0,0 +1,112 @@
<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: 500; font-weight: 600;
line-height: 1.2; line-height: 1.2;
color: #303133; color: #303133;
margin-bottom: 4px; margin-bottom: 4px;

View File

@@ -6,7 +6,7 @@
<div class="y-label">{{ formatFileSize(maxVal / 2) }}</div> <div class="y-label">{{ formatFileSize(maxVal / 2) }}</div>
<div class="y-label">0</div> <div class="y-label">0</div>
</div> </div>
<div class="bars-area"> <div class="bars-area">
<!-- Grid Lines --> <!-- Grid Lines -->
<div class="grid-line top"></div> <div class="grid-line top"></div>
@@ -15,21 +15,15 @@
<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 <el-tooltip :content="`In: ${formatFileSize(item.in)}`" placement="top">
:content="`In: ${formatFileSize(item.in)}`" <div
placement="top" class="bar bar-in"
>
<div
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 <el-tooltip :content="`Out: ${formatFileSize(item.out)}`" placement="top">
:content="`Out: ${formatFileSize(item.out)}`" <div
placement="top" class="bar bar-out"
>
<div
class="bar bar-out"
:style="{ height: Math.max(item.outPercent, 1) + '%' }" :style="{ height: Math.max(item.outPercent, 1) + '%' }"
></div> ></div>
</el-tooltip> </el-tooltip>
@@ -38,11 +32,15 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Legend --> <!-- Legend -->
<div v-if="!loading && chartData.length > 0" class="legend"> <div v-if="!loading && chartData.length > 0" class="legend">
<div class="legend-item"><span class="dot in"></span> Traffic In</div> <div class="legend-item">
<div class="legend-item"><span class="dot out"></span> Traffic Out</div> <span class="dot in"></span> Traffic In
</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" />
@@ -60,26 +58,24 @@ const props = defineProps<{
}>() }>()
const loading = ref(false) const loading = ref(false)
const chartData = ref< const chartData = ref<Array<{
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[]) => {
// Ensure we have arrays and reverse them (server returns newest first) // Ensure we have arrays and reverse them (server returns newest first)
const inArr = [...(trafficIn || [])].reverse() const inArr = [...(trafficIn || [])].reverse()
const outArr = [...(trafficOut || [])].reverse() const outArr = [...(trafficOut || [])].reverse()
// Pad with zeros if less than 7 days // Pad with zeros if less than 7 days
while (inArr.length < 7) inArr.unshift(0) while (inArr.length < 7) inArr.unshift(0)
while (outArr.length < 7) outArr.unshift(0) while (outArr.length < 7) outArr.unshift(0)
// Slice to last 7 entries just in case // Slice to last 7 entries just in case
const finalIn = inArr.slice(-7) const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7) const finalOut = outArr.slice(-7)
@@ -88,7 +84,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => {
const dates: string[] = [] const dates: string[] = []
let d = new Date() let d = new Date()
d.setDate(d.getDate() - 6) d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`) dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1) d.setDate(d.getDate() + 1)
@@ -183,16 +179,9 @@ html.dark .grid-line {
background-color: #3a3d5c; background-color: #3a3d5c;
} }
.grid-line.top { .grid-line.top { top: 0; }
top: 0; .grid-line.middle { top: 50%; transform: translateY(-50%); }
} .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;
@@ -266,10 +255,6 @@ html.dark .legend-item {
border-radius: 50%; border-radius: 50%;
} }
.dot.in { .dot.in { background-color: #5470c6; }
background-color: #5470c6; .dot.out { background-color: #91cc75; }
} </style>
.dot.out {
background-color: #91cc75;
}
</style>

View File

@@ -1,15 +1,10 @@
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: '/',
@@ -21,21 +16,11 @@ 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,8 +1,6 @@
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

@@ -12,7 +12,7 @@ export interface ServerInfo {
heartbeatTimeout: number heartbeatTimeout: number
allowPortsStr: string allowPortsStr: string
tlsForce: boolean tlsForce: boolean
// Stats // Stats
totalTrafficIn: number totalTrafficIn: number
totalTrafficOut: number totalTrafficOut: number

View File

@@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
// Prevent index out of bounds for extremely large numbers // Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1] const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i) const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit return parseFloat(val.toFixed(2)) + ' ' + unit
} }

View File

@@ -10,8 +10,6 @@ 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
@@ -21,10 +19,6 @@ 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 = ''
@@ -47,8 +41,6 @@ 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 = ''
@@ -57,8 +49,6 @@ class BaseProxy {
this.hostHeaderRewrite = '' this.hostHeaderRewrite = ''
this.locations = '' this.locations = ''
this.subdomain = '' this.subdomain = ''
this.multiplexer = ''
this.routeByHTTPUser = ''
} }
} }
@@ -121,15 +111,20 @@ 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

@@ -1,522 +0,0 @@
<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,48 +1,34 @@
<template> <template>
<div class="clients-page"> <div class="clients-page">
<div class="page-header"> <div class="filter-bar">
<div class="header-top"> <el-input
<div class="title-section"> v-model="searchText"
<h1 class="page-title">Clients</h1> placeholder="Search by hostname, user, client ID, run ID..."
<p class="page-subtitle">Manage connected clients and their status</p> :prefix-icon="Search"
</div> clearable
<div class="status-tabs"> class="search-input"
<button />
v-for="tab in statusTabs" <el-radio-group v-model="statusFilter" class="status-filter">
:key="tab.value" <el-radio-button label="all">All ({{ stats.total }})</el-radio-button>
class="status-tab" <el-radio-button label="online">
:class="{ active: statusFilter === tab.value }" Online ({{ stats.online }})
@click="statusFilter = tab.value" </el-radio-button>
> <el-radio-button label="offline">
<span class="status-dot" :class="tab.value"></span> Offline ({{ stats.offline }})
<span class="tab-label">{{ tab.label }}</span> </el-radio-button>
<span class="tab-count">{{ tab.count }}</span> </el-radio-group>
</button>
</div>
</div>
<div class="search-section">
<el-input
v-model="searchText"
placeholder="Search clients..."
:prefix-icon="Search"
clearable
class="search-input"
/>
</div>
</div> </div>
<div v-loading="loading" class="clients-content"> <div v-loading="loading" class="clients-grid">
<div v-if="filteredClients.length > 0" class="clients-list"> <el-empty
<ClientCard v-if="filteredClients.length === 0 && !loading"
v-for="client in filteredClients" description="No clients found"
:key="client.key" />
:client="client" <ClientCard
/> v-for="client in filteredClients"
</div> :key="client.key"
<div v-else-if="!loading" class="empty-state"> :client="client"
<el-empty description="No clients found" /> />
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -69,12 +55,6 @@ 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
@@ -107,6 +87,7 @@ 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,
@@ -118,6 +99,7 @@ const fetchData = async () => {
} }
const startAutoRefresh = () => { const startAutoRefresh = () => {
// Auto refresh every 5 seconds
refreshTimer = window.setInterval(() => { refreshTimer = window.setInterval(() => {
fetchData() fetchData()
}, 5000) }, 5000)
@@ -142,161 +124,46 @@ onUnmounted(() => {
<style scoped> <style scoped>
.clients-page { .clients-page {
display: flex; padding: 0 20px 20px 20px;
flex-direction: column;
gap: 24px;
} }
.page-header { .filter-bar {
display: flex; display: flex;
flex-direction: column; gap: 16px;
gap: 24px; align-items: center;
} margin-bottom: 20px;
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.title-section { .search-input {
display: flex; flex: 1;
flex-direction: column; min-width: 300px;
gap: 8px; max-width: 500px;
} }
.page-title { .status-filter {
font-size: 28px; flex-shrink: 0;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
} }
.page-subtitle { .clients-grid {
font-size: 14px; display: grid;
color: var(--el-text-color-secondary); grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
margin: 0; gap: 20px;
}
.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;
} }
.clients-list { @media (max-width: 768px) {
display: flex; .clients-grid {
flex-direction: column; grid-template-columns: 1fr;
gap: 16px; }
}
.empty-state { .filter-bar {
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; flex-direction: column;
align-items: flex-start; align-items: stretch;
} }
.status-tabs { .search-input {
width: 100%; max-width: none;
overflow-x: auto;
padding-bottom: 4px;
}
.status-tab {
flex-shrink: 0;
} }
} }
</style> </style>

View File

@@ -1,26 +1,34 @@
<template> <template>
<div class="proxies-page"> <div class="proxies-page">
<div class="page-header"> <!-- Main Content -->
<div class="header-top"> <el-card class="main-card" shadow="never">
<div class="title-section"> <div class="toolbar-header">
<h1 class="page-title">Proxies</h1> <el-tabs v-model="activeType" class="proxy-tabs">
<p class="page-subtitle">View and manage all proxy configurations</p> <el-tab-pane
</div> v-for="t in proxyTypes"
:key="t.value"
<div class="actions-section"> :label="t.label"
<el-button :icon="Refresh" class="action-btn" @click="fetchData" :name="t.value"
>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="Clear all offline proxies?" title="Are you sure to clear all data of offline proxies?"
width="220"
confirm-button-text="Clear"
cancel-button-text="Cancel"
@confirm="clearOfflineProxies" @confirm="clearOfflineProxies"
> >
<template #reference> <template #reference>
<el-button :icon="Delete" class="action-btn" type="danger" plain <el-button type="danger" plain :icon="Delete"
>Clear Offline</el-button >Clear Offline</el-button
> >
</template> </template>
@@ -28,74 +36,118 @@
</div> </div>
</div> </div>
<div class="filter-section"> <el-table
<div class="search-row"> v-loading="loading"
<el-input :data="filteredProxies"
v-model="searchText" :default-sort="{ prop: 'name', order: 'ascending' }"
placeholder="Search proxies..." style="width: 100%"
:prefix-icon="Search" >
clearable <el-table-column type="expand">
class="main-search" <template #default="props">
/> <div class="expand-wrapper">
<ProxyViewExpand :row="props.row" :proxyType="activeType" />
<el-select </div>
:model-value="selectedClientKey" </template>
placeholder="All Clients" </el-table-column>
clearable <el-table-column
filterable label="Name"
class="client-select" prop="name"
@change="onClientFilterChange" sortable
> min-width="150"
<el-option label="All Clients" value="" /> show-overflow-tooltip
<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> <el-table-column label="Port" prop="port" sortable width="100" />
<div v-else-if="!loading" class="empty-state"> <el-table-column
<el-empty description="No proxies found" /> label="Conns"
</div> prop="conns"
</div> 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
v-model="dialogVisible"
destroy-on-close
:title="`Traffic Statistics - ${dialogVisibleName}`"
width="700px"
align-center
class="traffic-dialog"
>
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
</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 { Search, Refresh, Delete } from '@element-plus/icons-vue' import {
Search,
Refresh,
Delete,
Top,
Bottom,
DataAnalysis,
} from '@element-plus/icons-vue'
import { import {
BaseProxy, BaseProxy,
TCPProxy, TCPProxy,
@@ -106,14 +158,10 @@ import {
STCPProxy, STCPProxy,
SUDPProxy, SUDPProxy,
} from '../utils/proxy' } from '../utils/proxy'
import ProxyCard from '../components/ProxyCard.vue' import ProxyViewExpand from '../components/ProxyViewExpand.vue'
import { import Traffic from '../components/Traffic.vue'
getProxiesByType, import { getProxiesByType, clearOfflineProxies as apiClearOfflineProxies } from '../api/proxy'
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()
@@ -130,85 +178,19 @@ 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 clientIDFilter = ref((route.query.clientID as string) || '') const dialogVisible = ref(false)
const userFilter = ref((route.query.user as string) || '') const dialogVisibleName = ref('')
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(() => {
let result = proxies.value if (!searchText.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,
)
} }
const search = searchText.value.toLowerCase()
// Filter by search text return proxies.value.filter((p) => p.name.toLowerCase().includes(search))
if (searchText.value) {
const search = searchText.value.toLowerCase()
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
@@ -265,6 +247,7 @@ 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,
@@ -275,6 +258,11 @@ 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()
@@ -293,177 +281,95 @@ const clearOfflineProxies = async () => {
// Watch for type changes // Watch for type changes
watch(activeType, (newType) => { watch(activeType, (newType) => {
// Update route but preserve query params router.replace({ params: { type: newType } })
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 {
display: flex; padding: 24px;
flex-direction: column; max-width: 1600px;
gap: 24px; margin: 0 auto;
} }
.page-header { /* Main Content */
display: flex; .main-card {
flex-direction: column; border-radius: 12px;
gap: 24px; border: none;
} }
.header-top { .toolbar-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
gap: 20px; margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
} }
.title-section { .proxy-tabs :deep(.el-tabs__header) {
display: flex; margin-bottom: 0;
flex-direction: column;
gap: 8px;
} }
.page-title { .proxy-tabs :deep(.el-tabs__nav-wrap::after) {
font-size: 28px; height: 0;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
} }
.page-subtitle { .toolbar-actions {
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;
} }
.main-search { .search-input {
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;
} }
.client-select :deep(.el-select__wrapper) { /* Table Styling */
border-radius: 12px; .traffic-cell {
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;
gap: 8px;
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-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-transform: uppercase;
}
.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;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 4px;
font-size: 13px;
} }
.empty-state { .traffic-item {
padding: 60px 0; 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) { @media (max-width: 768px) {
.search-row { .toolbar-header {
flex-direction: column; flex-direction: column;
align-items: stretch;
} }
.client-select { .toolbar-actions {
width: 100%; justify-content: space-between;
}
.search-input {
flex: 1;
} }
} }
</style> </style>

View File

@@ -1,985 +0,0 @@
<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,9 +53,7 @@
</div> </div>
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Inbound</div> <div class="label">Inbound</div>
<div class="value"> <div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div>
{{ formatFileSize(data.totalTrafficIn) }}
</div>
</div> </div>
</div> </div>
<div class="traffic-divider"></div> <div class="traffic-divider"></div>
@@ -65,9 +63,7 @@
</div> </div>
<div class="traffic-info"> <div class="traffic-info">
<div class="label">Outbound</div> <div class="label">Outbound</div>
<div class="value"> <div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div>
{{ formatFileSize(data.totalTrafficOut) }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -82,9 +78,9 @@
</div> </div>
</template> </template>
<div class="proxy-types-grid"> <div class="proxy-types-grid">
<div <div
v-for="(count, type) in data.proxyTypeCounts" v-for="(count, type) in data.proxyTypeCounts"
:key="type" :key="type"
class="proxy-type-item" class="proxy-type-item"
v-show="count > 0" v-show="count > 0"
> >
@@ -191,7 +187,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 = () => {
@@ -227,7 +223,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) {
@@ -287,7 +283,7 @@ html.dark .config-card {
.card-title { .card-title {
font-size: 16px; font-size: 16px;
font-weight: 500; font-weight: 600;
color: #303133; color: #303133;
} }
@@ -341,7 +337,7 @@ html.dark .card-title {
.traffic-info .value { .traffic-info .value {
font-size: 24px; font-size: 24px;
font-weight: 500; font-weight: 600;
color: #303133; color: #303133;
} }
@@ -390,7 +386,7 @@ html.dark .proxy-type-item {
.proxy-type-count { .proxy-type-count {
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 600;
color: #303133; color: #303133;
} }
@@ -441,7 +437,7 @@ html.dark .config-label {
.config-value { .config-value {
font-size: 14px; font-size: 14px;
color: #303133; color: #303133;
font-weight: 500; font-weight: 600;
word-break: break-all; word-break: break-all;
} }

View File

@@ -39,9 +39,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
allowedHosts: process.env.ALLOWED_HOSTS allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
? 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',