server: add client registry with dashboard support (#5115)

This commit is contained in:
fatedier
2026-01-08 20:07:14 +08:00
committed by GitHub
parent bc378bcbec
commit 36718d88e4
59 changed files with 4150 additions and 1837 deletions

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<title>frp client admin UI</title>
<script type="module" crossorigin src="./index-bLBhaJo8.js"></script>
<script type="module" crossorigin src="./index-HyKZ_pht.js"></script>
<link rel="stylesheet" crossorigin href="./index-iuf46MlF.css">
</head>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,9 @@
<head>
<meta charset="utf-8">
<title>frps dashboard</title>
<script type="module" crossorigin src="./index-82-40HIG.js"></script>
<link rel="stylesheet" crossorigin href="./index-rzPDshRD.css">
<title>frp server</title>
<script type="module" crossorigin src="./index-BUrDiw1t.js"></script>
<link rel="stylesheet" crossorigin href="./index-D4KRVvIu.css">
</head>
<body>

View File

@@ -281,11 +281,15 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
return
}
hostname, _ := os.Hostname()
loginMsg := &msg.Login{
Arch: runtime.GOARCH,
Os: runtime.GOOS,
Hostname: hostname,
PoolCount: svr.common.Transport.PoolCount,
User: svr.common.User,
ClientID: svr.common.ClientID,
Version: version.Full(),
Timestamp: time.Now().Unix(),
RunID: svr.runID,

View File

@@ -1,5 +1,7 @@
# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.
# Optional unique identifier for this frpc instance.
clientID = "your_client_id"
# your proxy name will be changed to {user}.{proxy}
user = "your_name"

View File

@@ -167,6 +167,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
}
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
cmd.PersistentFlags().StringVar(&c.ClientID, "client-id", "", "unique identifier for this frpc instance")
cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token")
}

View File

@@ -37,6 +37,8 @@ type ClientCommonConfig struct {
// clients. If this value is not "", proxy names will automatically be
// changed to "{user}.{proxy_name}".
User string `json:"user,omitempty"`
// ClientID uniquely identifies this frpc instance.
ClientID string `json:"clientID,omitempty"`
// ServerAddr specifies the address of the server to connect to. By
// default, this value is "0.0.0.0".

View File

@@ -82,6 +82,7 @@ type Login struct {
PrivilegeKey string `json:"privilege_key,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
RunID string `json:"run_id,omitempty"`
ClientID string `json:"client_id,omitempty"`
Metas map[string]string `json:"metas,omitempty"`
// Currently only effective for VirtualClient.

146
server/client_registry.go Normal file
View File

@@ -0,0 +1,146 @@
package server
import (
"fmt"
"maps"
"sync"
"time"
)
// ClientInfo captures metadata about a connected frpc instance.
type ClientInfo struct {
Key string
User string
ClientID string
RunID string
Hostname string
Metas map[string]string
FirstConnectedAt time.Time
LastConnectedAt time.Time
DisconnectedAt time.Time
Online bool
}
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (or runID if clientID is empty).
// Entries without an explicit clientID are removed on disconnect to avoid stale offline records.
type ClientRegistry struct {
mu sync.RWMutex
clients map[string]*ClientInfo
runIndex map[string]string
}
func NewClientRegistry() *ClientRegistry {
return &ClientRegistry{
clients: make(map[string]*ClientInfo),
runIndex: make(map[string]string),
}
}
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
func (cr *ClientRegistry) Register(user, clientID, runID, hostname string, metas map[string]string) (key string, conflict bool) {
if runID == "" {
return "", false
}
effectiveID := clientID
if effectiveID == "" {
effectiveID = runID
}
key = cr.composeClientKey(user, effectiveID)
enforceUnique := clientID != ""
now := time.Now()
cr.mu.Lock()
defer cr.mu.Unlock()
info, exists := cr.clients[key]
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
return key, true
}
if !exists {
info = &ClientInfo{
Key: key,
User: user,
ClientID: clientID,
FirstConnectedAt: now,
}
cr.clients[key] = info
} else if info.RunID != "" {
delete(cr.runIndex, info.RunID)
}
info.RunID = runID
info.Hostname = hostname
info.Metas = metas
if info.FirstConnectedAt.IsZero() {
info.FirstConnectedAt = now
}
info.LastConnectedAt = now
info.DisconnectedAt = time.Time{}
info.Online = true
cr.runIndex[runID] = key
return key, false
}
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
cr.mu.Lock()
defer cr.mu.Unlock()
key, ok := cr.runIndex[runID]
if !ok {
return
}
if info, ok := cr.clients[key]; ok && info.RunID == runID {
if info.ClientID == "" {
delete(cr.clients, key)
} else {
info.RunID = ""
info.Online = false
now := time.Now()
info.DisconnectedAt = now
}
}
delete(cr.runIndex, runID)
}
// List returns a snapshot of all known clients.
func (cr *ClientRegistry) List() []ClientInfo {
cr.mu.RLock()
defer cr.mu.RUnlock()
result := make([]ClientInfo, 0, len(cr.clients))
for _, info := range cr.clients {
cp := *info
cp.Metas = maps.Clone(info.Metas)
result = append(result, cp)
}
return result
}
// GetByKey retrieves a client by its composite key ({user}.{clientID} or runID fallback).
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
cr.mu.RLock()
defer cr.mu.RUnlock()
info, ok := cr.clients[key]
if !ok {
return ClientInfo{}, false
}
cp := *info
cp.Metas = maps.Clone(info.Metas)
return cp, true
}
func (cr *ClientRegistry) composeClientKey(user, id string) string {
switch {
case user == "":
return id
case id == "":
return user
default:
return fmt.Sprintf("%s.%s", user, id)
}
}

View File

@@ -147,6 +147,8 @@ type Control struct {
// Server configuration information
serverCfg *v1.ServerConfig
clientRegistry *ClientRegistry
xl *xlog.Logger
ctx context.Context
doneCh chan struct{}
@@ -358,6 +360,7 @@ func (ctl *Control) worker() {
}
metrics.Server.CloseClient()
ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
xl.Infof("client exit success")
close(ctl.doneCh)
}

View File

@@ -17,8 +17,11 @@ package server
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -53,6 +56,8 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET")
subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET")
subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET")
subRouter.HandleFunc("/api/clients", svr.apiClientList).Methods("GET")
subRouter.HandleFunc("/api/clients/{key}", svr.apiClientDetail).Methods("GET")
subRouter.HandleFunc("/api/proxies", svr.deleteProxies).Methods("DELETE")
// view
@@ -88,6 +93,19 @@ type serverInfoResp struct {
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
}
type clientInfoResp struct {
Key string `json:"key"`
User string `json:"user"`
ClientID string `json:"clientId"`
RunID string `json:"runId"`
Hostname string `json:"hostname"`
Metas map[string]string `json:"metas,omitempty"`
FirstConnectedAt int64 `json:"firstConnectedAt"`
LastConnectedAt int64 `json:"lastConnectedAt"`
DisconnectedAt int64 `json:"disconnectedAt,omitempty"`
Online bool `json:"online"`
}
// /healthz
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
@@ -132,6 +150,101 @@ func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
res.Msg = string(buf)
}
// /api/clients
func (svr *Service) apiClientList(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.RequestURI())
if svr.clientRegistry == nil {
res.Code = http.StatusInternalServerError
res.Msg = "client registry unavailable"
return
}
query := r.URL.Query()
userFilter := query.Get("user")
clientIDFilter := query.Get("clientId")
runIDFilter := query.Get("runId")
statusFilter := strings.ToLower(query.Get("status"))
records := svr.clientRegistry.List()
items := make([]clientInfoResp, 0, len(records))
for _, info := range records {
if userFilter != "" && info.User != userFilter {
continue
}
if clientIDFilter != "" && info.ClientID != clientIDFilter {
continue
}
if runIDFilter != "" && info.RunID != runIDFilter {
continue
}
if !matchStatusFilter(info.Online, statusFilter) {
continue
}
items = append(items, buildClientInfoResp(info))
}
slices.SortFunc(items, func(a, b clientInfoResp) int {
if v := cmp.Compare(a.User, b.User); v != 0 {
return v
}
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
return v
}
return cmp.Compare(a.Key, b.Key)
})
buf, _ := json.Marshal(items)
res.Msg = string(buf)
}
// /api/clients/{key}
func (svr *Service) apiClientDetail(w http.ResponseWriter, r *http.Request) {
res := GeneralResponse{Code: 200}
defer func() {
log.Infof("http response [%s]: code [%d]", r.URL.RequestURI(), res.Code)
w.WriteHeader(res.Code)
if len(res.Msg) > 0 {
_, _ = w.Write([]byte(res.Msg))
}
}()
log.Infof("http request: [%s]", r.URL.RequestURI())
vars := mux.Vars(r)
key := vars["key"]
if key == "" {
res.Code = http.StatusBadRequest
res.Msg = "missing client key"
return
}
if svr.clientRegistry == nil {
res.Code = http.StatusInternalServerError
res.Msg = "client registry unavailable"
return
}
info, ok := svr.clientRegistry.GetByKey(key)
if !ok {
res.Code = http.StatusNotFound
res.Msg = fmt.Sprintf("client %s not found", key)
return
}
buf, _ := json.Marshal(buildClientInfoResp(info))
res.Msg = string(buf)
}
type BaseOutConf struct {
v1.ProxyBaseConfig
}
@@ -404,3 +517,41 @@ func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
cleared, total := mem.StatsCollector.ClearOfflineProxies()
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
}
func buildClientInfoResp(info ClientInfo) clientInfoResp {
resp := clientInfoResp{
Key: info.Key,
User: info.User,
ClientID: info.ClientID,
RunID: info.RunID,
Hostname: info.Hostname,
Metas: info.Metas,
FirstConnectedAt: toUnix(info.FirstConnectedAt),
LastConnectedAt: toUnix(info.LastConnectedAt),
Online: info.Online,
}
if !info.DisconnectedAt.IsZero() {
resp.DisconnectedAt = info.DisconnectedAt.Unix()
}
return resp
}
func toUnix(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}
func matchStatusFilter(online bool, filter string) bool {
switch strings.ToLower(filter) {
case "", "all":
return true
case "online":
return online
case "offline":
return !online
default:
return true
}
}

View File

@@ -96,6 +96,9 @@ type Service struct {
// Manage all controllers
ctlManager *ControlManager
// Track logical clients keyed by user.clientID.
clientRegistry *ClientRegistry
// Manage all proxies
pxyManager *proxy.Manager
@@ -155,9 +158,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
}
svr := &Service{
ctlManager: NewControlManager(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
ctlManager: NewControlManager(),
clientRegistry: NewClientRegistry(),
pxyManager: proxy.NewManager(),
pluginManager: plugin.NewManager(),
rc: &controller.ResourceController{
VisitorManager: visitor.NewManager(),
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
@@ -606,10 +610,19 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
// don't return detailed errors to client
return fmt.Errorf("unexpected error when creating new controller")
}
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
oldCtl.WaitClosed()
}
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, loginMsg.Metas)
if conflict {
svr.ctlManager.Del(loginMsg.RunID, ctl)
ctl.Close()
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
}
ctl.clientRegistry = svr.clientRegistry
ctl.Start()
// for statistics

View File

@@ -23,7 +23,7 @@ module.exports = {
'vue/multi-word-component-names': [
'error',
{
ignores: ['Traffic'],
ignores: ['Traffic', 'Proxies', 'Clients'],
},
],
},

View File

@@ -7,37 +7,38 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
ClientCard: typeof import('./src/components/ClientCard.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
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']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
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']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
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']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
LongSpan: typeof import('./src/components/LongSpan.vue')['default']
ProxiesHTTP: typeof import('./src/components/ProxiesHTTP.vue')['default']
ProxiesHTTPS: typeof import('./src/components/ProxiesHTTPS.vue')['default']
ProxiesSTCP: typeof import('./src/components/ProxiesSTCP.vue')['default']
ProxiesSUDP: typeof import('./src/components/ProxiesSUDP.vue')['default']
ProxiesTCP: typeof import('./src/components/ProxiesTCP.vue')['default']
ProxiesTCPMux: typeof import('./src/components/ProxiesTCPMux.vue')['default']
ProxiesUDP: typeof import('./src/components/ProxiesUDP.vue')['default']
ProxyView: typeof import('./src/components/ProxyView.vue')['default']
ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
ServerOverview: typeof import('./src/components/ServerOverview.vue')['default']
StatCard: typeof import('./src/components/StatCard.vue')['default']
Traffic: typeof import('./src/components/Traffic.vue')['default']
}
export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<title>frps dashboard</title>
<title>frp server</title>
</head>
<body>

View File

@@ -11,28 +11,30 @@
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@types/humanize-plus": "^1.8.0",
"echarts": "^5.4.3",
"element-plus": "^2.5.3",
"humanize-plus": "^1.8.2",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
"element-plus": "^2.13.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.7.2",
"@types/node": "^18.11.12",
"@vitejs/plugin-vue": "^5.0.3",
"@rushstack/eslint-patch": "^1.15.0",
"@types/node": "24",
"@vitejs/plugin-vue": "^6.0.3",
"@vue/eslint-config-prettier": "^9.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"@vue/tsconfig": "^0.8.1",
"@vueuse/core": "^14.1.0",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.21.0",
"eslint-plugin-vue": "^9.33.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.2.4",
"typescript": "~5.3.3",
"prettier": "^3.7.4",
"sass": "^1.97.2",
"terser": "^5.44.1",
"typescript": "^5.9.3",
"unplugin-auto-import": "^0.17.5",
"unplugin-element-plus": "^0.11.2",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.12",
"vue-tsc": "^1.8.27"
"vite": "^7.3.0",
"vite-svg-loader": "^5.1.0",
"vue-tsc": "^3.2.2"
}
}

View File

@@ -1,127 +1,300 @@
<template>
<div id="app">
<header class="grid-content header-color">
<div class="header-content">
<header class="header">
<div class="header-top">
<div class="brand">
<a href="#">frp</a>
<a href="#" @click.prevent="router.push('/')">frp</a>
</div>
<div class="dark-switch">
<div class="header-actions">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="darkmodeSwitch"
inline-prompt
active-text="Dark"
inactive-text="Light"
:active-icon="Moon"
:inactive-icon="Sunny"
@change="toggleDark"
style="
--el-switch-on-color: #444452;
--el-switch-off-color: #589ef8;
"
class="theme-switch"
/>
</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>
<section>
<el-row>
<el-col id="side-nav" :xs="24" :md="4">
<el-menu
default-active="/"
mode="vertical"
theme="light"
router="false"
@select="handleSelect"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-sub-menu index="/proxies">
<template #title>
<span>Proxies</span>
</template>
<el-menu-item index="/proxies/tcp">TCP</el-menu-item>
<el-menu-item index="/proxies/udp">UDP</el-menu-item>
<el-menu-item index="/proxies/http">HTTP</el-menu-item>
<el-menu-item index="/proxies/https">HTTPS</el-menu-item>
<el-menu-item index="/proxies/tcpmux">TCPMUX</el-menu-item>
<el-menu-item index="/proxies/stcp">STCP</el-menu-item>
<el-menu-item index="/proxies/sudp">SUDP</el-menu-item>
</el-sub-menu>
<el-menu-item index="">Help</el-menu-item>
</el-menu>
</el-col>
<el-col :xs="24" :md="20">
<div id="content">
<router-view></router-view>
</div>
</el-col>
</el-row>
</section>
<footer></footer>
<main id="content">
<router-view></router-view>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component'
const router = useRouter()
const route = useRoute()
const isDark = useDark()
const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark)
const handleSelect = (key: string) => {
if (key == '') {
window.open('https://github.com/fatedier/frp')
const currentRoute = computed(() => {
// Normalize /proxies/:type to /proxies for menu highlighting
if (route.path.startsWith('/proxies')) {
return '/proxies'
}
return route.path
})
const handleSelect = (key: string) => {
router.push(key)
}
</script>
<style>
body {
margin: 0px;
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif;
margin: 0;
font-family:
-apple-system,
BlinkMacSystemFont,
Helvetica Neue,
sans-serif;
}
header {
width: 100%;
height: 60px;
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f2f2f2;
}
.header-color {
background: #58b7ff;
html.dark #app {
background: #1a1a2e;
}
html.dark .header-color {
background: #395c74;
.header {
position: sticky;
top: 0;
z-index: 100;
background: #fff;
}
.header-content {
html.dark .header {
background: #1e1e2d;
}
.header-top {
display: flex;
align-items: center;
}
#content {
margin-top: 20px;
padding-right: 40px;
}
.brand {
display: flex;
justify-content: flex-start;
justify-content: space-between;
height: 48px;
padding: 0 32px;
}
.brand a {
color: #fff;
background-color: transparent;
margin-left: 20px;
line-height: 25px;
font-size: 25px;
padding: 15px 15px;
height: 30px;
color: #303133;
font-size: 20px;
font-weight: 700;
text-decoration: none;
letter-spacing: -0.5px;
}
.dark-switch {
html.dark .brand a {
color: #e5e7eb;
}
.brand a:hover {
color: #409eff;
}
.header-actions {
display: flex;
justify-content: flex-end;
flex-grow: 1;
padding-right: 40px;
align-items: center;
gap: 16px;
}
.github-link {
display: flex;
align-items: center;
padding: 6px;
border-radius: 6px;
transition: all 0.2s;
}
.github-link:hover {
background: #f2f3f5;
}
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 {
--el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2;
--el-switch-border-color: #dcdfe6;
}
.theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important;
}
.header-nav {
position: relative;
padding: 0 32px;
border-bottom: 1px solid #e4e7ed;
}
html.dark .header-nav {
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;
padding: 0 12px !important;
margin: 7px 0;
transition:
background 0.15s ease,
color 0.15s ease;
}
.nav-menu > .el-menu-item,
.nav-menu > .el-sub-menu {
margin-right: 4px;
}
.nav-menu > .el-sub-menu {
padding: 0 !important;
}
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 {
flex: 1;
padding: 24px 40px;
max-width: 1400px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
@media (max-width: 768px) {
.header-top {
padding: 0 16px;
}
.header-nav {
padding: 0 16px;
}
#content {
padding: 16px;
}
.brand a {
font-size: 18px;
}
}
</style>

View File

@@ -0,0 +1,10 @@
import { http } from './http'
import type { ClientInfoData } from '../types/client'
export const getClients = () => {
return http.get<ClientInfoData[]>('../api/clients')
}
export const getClient = (key: string) => {
return http.get<ClientInfoData>(`../api/clients/${key}`)
}

50
web/frps/src/api/http.ts Normal file
View File

@@ -0,0 +1,50 @@
// http.ts - Base HTTP client
class HTTPError extends Error {
status: number
statusText: string
constructor(status: number, statusText: string, message?: string) {
super(message || statusText)
this.status = status
this.statusText = statusText
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const defaultOptions: RequestInit = {
credentials: 'include',
}
const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) {
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
}
// Handle empty response (e.g. 204 No Content)
if (response.status === 204) {
return {} as T
}
return response.json()
}
export const http = {
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'POST',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body)
}),
put: <T>(url: string, body?: any, options?: RequestInit) =>
request<T>(url, {
...options,
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...options?.headers },
body: JSON.stringify(body)
}),
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
}

18
web/frps/src/api/proxy.ts Normal file
View File

@@ -0,0 +1,18 @@
import { http } from './http'
import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy'
export const getProxiesByType = (type: string) => {
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
}
export const getProxy = (type: string, name: string) => {
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
}
export const getProxyTraffic = (name: string) => {
return http.get<TrafficResponse>(`../api/traffic/${name}`)
}
export const clearOfflineProxies = () => {
return http.delete('../api/proxies?status=offline')
}

View File

@@ -0,0 +1,6 @@
import { http } from './http'
import type { ServerInfo } from '../types/server'
export const getServerInfo = () => {
return http.get<ServerInfo>('../api/serverinfo')
}

View File

@@ -0,0 +1,89 @@
.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;
}
/* Smooth transitions */
.el-button,
.el-card,
.el-input,
.el-select,
.el-tag {
transition: all 0.3s ease;
}
/* Card hover effects */
.el-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
/* Better scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Page headers */
.el-page-header {
padding: 16px 0;
}
.el-page-header__title {
font-size: 20px;
font-weight: 600;
}
/* Better form layouts */
.el-form-item {
margin-bottom: 18px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.el-row {
margin-left: 0 !important;
margin-right: 0 !important;
}
.el-col {
padding-left: 10px !important;
padding-right: 10px !important;
}
}

View File

@@ -0,0 +1,58 @@
html.dark {
--el-bg-color: #1e1e2e;
--el-fill-color-blank: #1e1e2e;
background-color: #1e1e2e;
}
html.dark body {
background-color: #1e1e2e;
color: #e5e7eb;
}
/* Dark mode scrollbar */
html.dark ::-webkit-scrollbar-track {
background: #27293d;
}
html.dark ::-webkit-scrollbar-thumb {
background: #3a3d5c;
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: #4a4d6c;
}
/* Dark mode cards */
html.dark .el-card {
background-color: #27293d;
border-color: #3a3d5c;
}
/* Dark mode inputs */
html.dark .el-input__wrapper {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-input__inner {
color: #e5e7eb;
}
/* Dark mode table */
html.dark .el-table {
background-color: #27293d;
color: #e5e7eb;
}
html.dark .el-table th {
background-color: #1e1e2e;
color: #e5e7eb;
}
html.dark .el-table tr {
background-color: #27293d;
}
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
background-color: #1e1e2e;
}

View File

@@ -1,22 +0,0 @@
.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;
}

View File

@@ -1,5 +0,0 @@
html.dark {
--el-bg-color: #343432;
--el-fill-color-blank: #343432;
background-color: #343432;
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
</svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -0,0 +1,229 @@
<template>
<el-card class="client-card" shadow="hover" :body-style="{ padding: '20px' }">
<div class="client-header">
<div class="client-status">
<span class="status-dot" :class="statusClass"></span>
<span class="client-name">{{ client.displayName }}</span>
</div>
<el-tag :type="client.statusColor" size="small">
{{ client.online ? 'Online' : 'Offline' }}
</el-tag>
</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.user">
<el-icon class="info-icon"><User /></el-icon>
<span class="info-label">User:</span>
<span class="info-value">{{ client.user }}</span>
</div>
<div class="info-row">
<el-icon class="info-icon"><Key /></el-icon>
<span class="info-label">Run ID:</span>
<span class="info-value monospace">{{ client.runId }}</span>
</div>
<div class="info-row" v-if="client.firstConnectedAt">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">First Connected:</span>
<span class="info-value">{{ client.firstConnectedAgo }}</span>
</div>
<div class="info-row" v-if="client.online">
<el-icon class="info-icon"><Clock /></el-icon>
<span class="info-label">Last Connected:</span>
<span class="info-value">{{ client.lastConnectedAgo }}</span>
</div>
<div class="info-row" v-if="!client.online && client.disconnectedAt">
<el-icon class="info-icon"><CircleClose /></el-icon>
<span class="info-label">Disconnected:</span>
<span class="info-value">{{ client.disconnectedAgo }}</span>
</div>
</div>
<div class="client-metas" v-if="client.metasArray.length > 0">
<div class="metas-label">Metadata:</div>
<div class="metas-tags">
<el-tag
v-for="meta in client.metasArray"
:key="meta.key"
size="small"
type="info"
class="meta-tag"
>
{{ meta.key }}: {{ meta.value }}
</el-tag>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Monitor, User, Key, Clock, CircleClose } from '@element-plus/icons-vue'
import type { Client } from '../utils/client'
interface Props {
client: Client
}
const props = defineProps<Props>()
const statusClass = computed(() => {
return `status-${props.client.statusColor}`
})
</script>
<style scoped>
.client-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.client-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
html.dark .client-card {
border-color: #3a3d5c;
background: #27293d;
}
.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;
align-items: center;
gap: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-success {
background-color: #67c23a;
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7);
}
.status-warning {
background-color: #e6a23c;
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
}
.status-danger {
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;
flex-direction: column;
gap: 10px;
margin-bottom: 16px;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.info-icon {
color: #909399;
font-size: 16px;
}
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;
}
.client-metas {
margin-bottom: 16px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
}
html.dark .client-metas {
border-top-color: #3a3d5c;
}
.metas-label {
font-size: 13px;
color: #909399;
font-weight: 500;
margin-bottom: 8px;
}
html.dark .metas-label {
color: #9ca3af;
}
.metas-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.meta-tag {
font-size: 12px;
}
.monospace {
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
word-break: break-all;
}
</style>

View File

@@ -1,15 +0,0 @@
<template>
<el-tooltip :content="content" placement="top">
<span v-show="content.length > length"
>{{ content.slice(0, length) }}...</span
>
</el-tooltip>
<span v-show="content.length < 30">{{ content }}</span>
</template>
<script setup lang="ts">
defineProps<{
content: string
length: number
}>()
</script>

View File

@@ -1,42 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="http" @refresh="fetchData"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { HTTPProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<HTTPProxy[]>([])
const fetchData = () => {
let vhostHTTPPort: number
let subdomainHost: string
fetch('../api/serverinfo', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
vhostHTTPPort = json.vhostHTTPPort
subdomainHost = json.subdomainHost
if (vhostHTTPPort == null || vhostHTTPPort == 0) {
return
}
fetch('../api/proxy/http', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(
new HTTPProxy(proxyStats, vhostHTTPPort, subdomainHost)
)
}
})
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,42 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="https" @refresh="fetchData"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { HTTPSProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<HTTPSProxy[]>([])
const fetchData = () => {
let vhostHTTPSPort: number
let subdomainHost: string
fetch('../api/serverinfo', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
vhostHTTPSPort = json.vhostHTTPSPort
subdomainHost = json.subdomainHost
if (vhostHTTPSPort == null || vhostHTTPSPort == 0) {
return
}
fetch('../api/proxy/https', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(
new HTTPSProxy(proxyStats, vhostHTTPSPort, subdomainHost)
)
}
})
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,27 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="stcp" @refresh="fetchData"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { STCPProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<STCPProxy[]>([])
const fetchData = () => {
fetch('../api/proxy/stcp', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(new STCPProxy(proxyStats))
}
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,27 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="sudp" @refresh="fetchData"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { SUDPProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<SUDPProxy[]>([])
const fetchData = () => {
fetch('../api/proxy/sudp', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(new SUDPProxy(proxyStats))
}
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,27 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="tcp" @refresh="fetchData" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TCPProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<TCPProxy[]>([])
const fetchData = () => {
fetch('../api/proxy/tcp', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(new TCPProxy(proxyStats))
}
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,38 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="tcpmux" @refresh="fetchData" />
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TCPMuxProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<TCPMuxProxy[]>([])
const fetchData = () => {
let tcpmuxHTTPConnectPort: number
let subdomainHost: string
fetch('../api/serverinfo', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
subdomainHost = json.subdomainHost
fetch('../api/proxy/tcpmux', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(new TCPMuxProxy(proxyStats, tcpmuxHTTPConnectPort, subdomainHost))
}
})
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,27 +0,0 @@
<template>
<ProxyView :proxies="proxies" proxyType="udp" @refresh="fetchData"/>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { UDPProxy } from '../utils/proxy.js'
import ProxyView from './ProxyView.vue'
let proxies = ref<UDPProxy[]>([])
const fetchData = () => {
fetch('../api/proxy/udp', { credentials: 'include' })
.then((res) => {
return res.json()
})
.then((json) => {
proxies.value = []
for (let proxyStats of json.proxies) {
proxies.value.push(new UDPProxy(proxyStats))
}
})
}
fetchData()
</script>
<style></style>

View File

@@ -1,145 +0,0 @@
<template>
<div>
<el-page-header
:icon="null"
style="width: 100%; margin-left: 30px; margin-bottom: 20px"
>
<template #title>
<span>{{ proxyType }}</span>
</template>
<template #content> </template>
<template #extra>
<div class="flex items-center" style="margin-right: 30px">
<el-popconfirm
title="Are you sure to clear all data of offline proxies?"
@confirm="clearOfflineProxies"
>
<template #reference>
<el-button>ClearOfflineProxies</el-button>
</template>
</el-popconfirm>
<el-button @click="$emit('refresh')">Refresh</el-button>
</div>
</template>
</el-page-header>
<el-table
:data="proxies"
:default-sort="{ prop: 'name', order: 'ascending' }"
style="width: 100%"
>
<el-table-column type="expand">
<template #default="props">
<ProxyViewExpand :row="props.row" :proxyType="proxyType" />
</template>
</el-table-column>
<el-table-column label="Name" prop="name" sortable> </el-table-column>
<el-table-column label="Port" prop="port" sortable> </el-table-column>
<el-table-column label="Connections" prop="conns" sortable>
</el-table-column>
<el-table-column
label="Traffic In"
prop="trafficIn"
:formatter="formatTrafficIn"
sortable
>
</el-table-column>
<el-table-column
label="Traffic Out"
prop="trafficOut"
:formatter="formatTrafficOut"
sortable
>
</el-table-column>
<el-table-column label="ClientVersion" prop="clientVersion" sortable>
</el-table-column>
<el-table-column label="Status" prop="status" sortable>
<template #default="scope">
<el-tag v-if="scope.row.status === 'online'" type="success">{{
scope.row.status
}}</el-tag>
<el-tag v-else type="danger">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="Operations">
<template #default="scope">
<el-button
type="primary"
:name="scope.row.name"
style="margin-bottom: 10px"
@click="dialogVisibleName = scope.row.name; dialogVisible = true"
>Traffic
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-dialog
v-model="dialogVisible"
destroy-on-close="true"
:title="dialogVisibleName"
width="700px">
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
</template>
<script setup lang="ts">
import * as Humanize from 'humanize-plus'
import type { TableColumnCtx } from 'element-plus'
import type { BaseProxy } from '../utils/proxy.js'
import { ElMessage } from 'element-plus'
import ProxyViewExpand from './ProxyViewExpand.vue'
import { ref } from 'vue'
defineProps<{
proxies: BaseProxy[]
proxyType: string
}>()
const emit = defineEmits(['refresh'])
const dialogVisible = ref(false)
const dialogVisibleName = ref("")
const formatTrafficIn = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => {
return Humanize.fileSize(row.trafficIn)
}
const formatTrafficOut = (row: BaseProxy, _: TableColumnCtx<BaseProxy>) => {
return Humanize.fileSize(row.trafficOut)
}
const clearOfflineProxies = () => {
fetch('../api/proxies?status=offline', {
method: 'DELETE',
credentials: 'include',
})
.then((res) => {
if (res.ok) {
ElMessage({
message: 'Successfully cleared offline proxies',
type: 'success',
})
emit('refresh')
} else {
ElMessage({
message: 'Failed to clear offline proxies: ' + res.status + ' ' + res.statusText,
type: 'warning',
})
}
})
.catch((err) => {
ElMessage({
message: 'Failed to clear offline proxies: ' + err.message,
type: 'warning',
})
})
}
</script>
<style>
.el-page-header__title {
font-size: 20px;
}
</style>

View File

@@ -60,19 +60,18 @@
</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()">
<span class="annotation-key">{{ item.key }}</span>
<span>{{ item.value }}</span>
</li>
</ul>
<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
@@ -80,13 +79,13 @@ const props = defineProps<{
// 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 }> = [];
const array: Array<{ key: string; value: any }> = []
if (props.row.annotations) {
props.row.annotations.forEach((value: any, key: string) => {
array.push({ key, value });
});
array.push({ key, value })
})
}
return array;
return array
}
</script>

View File

@@ -1,195 +0,0 @@
<template>
<div>
<el-row>
<el-col :md="12">
<div class="source">
<el-form
label-position="left"
label-width="220px"
class="server_info"
>
<el-form-item label="Version">
<span>{{ data.version }}</span>
</el-form-item>
<el-form-item label="BindPort">
<span>{{ data.bindPort }}</span>
</el-form-item>
<el-form-item label="KCP Bind Port" v-if="data.kcpBindPort != 0">
<span>{{ data.kcpBindPort }}</span>
</el-form-item>
<el-form-item label="QUIC Bind Port" v-if="data.quicBindPort != 0">
<span>{{ data.quicBindPort }}</span>
</el-form-item>
<el-form-item label="HTTP Port" v-if="data.vhostHTTPPort != 0">
<span>{{ data.vhostHTTPPort }}</span>
</el-form-item>
<el-form-item label="HTTPS Port" v-if="data.vhostHTTPSPort != 0">
<span>{{ data.vhostHTTPSPort }}</span>
</el-form-item>
<el-form-item
label="TCPMux HTTPConnect Port"
v-if="data.tcpmuxHTTPConnectPort != 0"
>
<span>{{ data.tcpmuxHTTPConnectPort }}</span>
</el-form-item>
<el-form-item
label="Subdomain Host"
v-if="data.subdomainHost != ''"
>
<LongSpan :content="data.subdomainHost" :length="30"></LongSpan>
</el-form-item>
<el-form-item label="Max PoolCount">
<span>{{ data.maxPoolCount }}</span>
</el-form-item>
<el-form-item label="Max Ports Per Client">
<span>{{ data.maxPortsPerClient }}</span>
</el-form-item>
<el-form-item label="Allow Ports" v-if="data.allowPortsStr != ''">
<LongSpan :content="data.allowPortsStr" :length="30"></LongSpan>
</el-form-item>
<el-form-item label="TLS Force" v-if="data.tlsForce === true">
<span>{{ data.tlsForce }}</span>
</el-form-item>
<el-form-item label="HeartBeat Timeout">
<span>{{ data.heartbeatTimeout }}</span>
</el-form-item>
<el-form-item label="Client Counts">
<span>{{ data.clientCounts }}</span>
</el-form-item>
<el-form-item label="Current Connections">
<span>{{ data.curConns }}</span>
</el-form-item>
<el-form-item label="Proxy Counts">
<span>{{ data.proxyCounts }}</span>
</el-form-item>
</el-form>
</div>
</el-col>
<el-col :md="12">
<div
id="traffic"
style="width: 400px; height: 250px; margin-bottom: 30px"
></div>
<div id="proxies" style="width: 400px; height: 250px"></div>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { DrawTrafficChart, DrawProxyChart } from '../utils/chart'
import LongSpan from './LongSpan.vue'
let data = ref({
version: '',
bindPort: 0,
kcpBindPort: 0,
quicBindPort: 0,
vhostHTTPPort: 0,
vhostHTTPSPort: 0,
tcpmuxHTTPConnectPort: 0,
subdomainHost: '',
maxPoolCount: 0,
maxPortsPerClient: '',
allowPortsStr: '',
tlsForce: false,
heartbeatTimeout: 0,
clientCounts: 0,
curConns: 0,
proxyCounts: 0,
})
const fetchData = () => {
fetch('../api/serverinfo', { credentials: 'include' })
.then((res) => res.json())
.then((json) => {
data.value.version = json.version
data.value.bindPort = json.bindPort
data.value.kcpBindPort = json.kcpBindPort
data.value.quicBindPort = json.quicBindPort
data.value.vhostHTTPPort = json.vhostHTTPPort
data.value.vhostHTTPSPort = json.vhostHTTPSPort
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
data.value.subdomainHost = json.subdomainHost
data.value.maxPoolCount = json.maxPoolCount
data.value.maxPortsPerClient = json.maxPortsPerClient
if (data.value.maxPortsPerClient == '0') {
data.value.maxPortsPerClient = 'no limit'
}
data.value.allowPortsStr = json.allowPortsStr
data.value.tlsForce = json.tlsForce
data.value.heartbeatTimeout = json.heartbeatTimeout
data.value.clientCounts = json.clientCounts
data.value.curConns = json.curConns
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
if (json.proxyTypeCount.tcp != null) {
data.value.proxyCounts += json.proxyTypeCount.tcp
}
if (json.proxyTypeCount.udp != null) {
data.value.proxyCounts += json.proxyTypeCount.udp
}
if (json.proxyTypeCount.http != null) {
data.value.proxyCounts += json.proxyTypeCount.http
}
if (json.proxyTypeCount.https != null) {
data.value.proxyCounts += json.proxyTypeCount.https
}
if (json.proxyTypeCount.stcp != null) {
data.value.proxyCounts += json.proxyTypeCount.stcp
}
if (json.proxyTypeCount.sudp != null) {
data.value.proxyCounts += json.proxyTypeCount.sudp
}
if (json.proxyTypeCount.xtcp != null) {
data.value.proxyCounts += json.proxyTypeCount.xtcp
}
}
// draw chart
DrawTrafficChart('traffic', json.totalTrafficIn, json.totalTrafficOut)
DrawProxyChart('proxies', json)
})
.catch(() => {
ElMessage({
showClose: true,
message: 'Get server info from frps failed!',
type: 'warning',
})
})
}
fetchData()
</script>
<style>
.source {
border-radius: 4px;
transition: 0.2s;
padding-left: 24px;
padding-right: 24px;
}
.server_info {
margin-left: 40px;
font-size: 0px;
}
.server_info .el-form-item__label {
color: #99a9bf;
height: 40px;
line-height: 40px;
}
.server_info .el-form-item__content {
height: 40px;
line-height: 40px;
}
.server_info .el-form-item {
margin-right: 0;
margin-bottom: 0;
width: 100%;
}
</style>

View File

@@ -0,0 +1,202 @@
<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 {
User,
Connection,
DataAnalysis,
Promotion,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'clients' | 'proxies' | 'connections' | 'traffic'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'clients',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'clients':
return User
case 'proxies':
return Connection
case 'connections':
return DataAnalysis
case 'traffic':
return Promotion
default:
return User
}
})
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-clients {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-proxies {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-connections {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
.icon-traffic {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
html.dark .icon-clients {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-connections {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
html.dark .icon-traffic {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 600;
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

@@ -1,32 +1,260 @@
<template>
<div :id="proxyName" style="width: 600px; height: 400px"></div>
<div class="traffic-chart-container" v-loading="loading">
<div v-if="!loading && chartData.length > 0" class="chart-wrapper">
<div class="y-axis">
<div class="y-label">{{ formatFileSize(maxVal) }}</div>
<div class="y-label">{{ formatFileSize(maxVal / 2) }}</div>
<div class="y-label">0</div>
</div>
<div class="bars-area">
<!-- Grid Lines -->
<div class="grid-line top"></div>
<div class="grid-line middle"></div>
<div class="grid-line bottom"></div>
<div v-for="(item, index) in chartData" :key="index" class="day-column">
<div class="bars-group">
<el-tooltip :content="`In: ${formatFileSize(item.in)}`" placement="top">
<div
class="bar bar-in"
:style="{ height: Math.max(item.inPercent, 1) + '%' }"
></div>
</el-tooltip>
<el-tooltip :content="`Out: ${formatFileSize(item.out)}`" placement="top">
<div
class="bar bar-out"
:style="{ height: Math.max(item.outPercent, 1) + '%' }"
></div>
</el-tooltip>
</div>
<div class="date-label">{{ item.date }}</div>
</div>
</div>
</div>
<!-- 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">
<span class="dot out"></span> Traffic Out
</div>
</div>
<el-empty v-else-if="!loading" description="No traffic data" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { DrawProxyTrafficChart } from '../utils/chart.js'
import { formatFileSize } from '../utils/format'
import { getProxyTraffic } from '../api/proxy'
const props = defineProps<{
proxyName: string
}>()
const loading = ref(false)
const chartData = ref<Array<{
date: string
in: number
out: number
inPercent: number
outPercent: number
}>>([])
const maxVal = ref(0)
const processData = (trafficIn: number[], trafficOut: number[]) => {
// Ensure we have arrays and reverse them (server returns newest first)
const inArr = [...(trafficIn || [])].reverse()
const outArr = [...(trafficOut || [])].reverse()
// Pad with zeros if less than 7 days
while (inArr.length < 7) inArr.unshift(0)
while (outArr.length < 7) outArr.unshift(0)
// Slice to last 7 entries just in case
const finalIn = inArr.slice(-7)
const finalOut = outArr.slice(-7)
// Calculate dates (last 7 days ending today)
const dates: string[] = []
let d = new Date()
d.setDate(d.getDate() - 6)
for (let i = 0; i < 7; i++) {
dates.push(`${d.getMonth() + 1}-${d.getDate()}`)
d.setDate(d.getDate() + 1)
}
// Find max value for scaling
const maxIn = Math.max(...finalIn)
const maxOut = Math.max(...finalOut)
maxVal.value = Math.max(maxIn, maxOut, 100) // Minimum scale 100 bytes
// Build chart data
chartData.value = dates.map((date, i) => ({
date,
in: finalIn[i],
out: finalOut[i],
inPercent: (finalIn[i] / maxVal.value) * 100,
outPercent: (finalOut[i] / maxVal.value) * 100,
}))
}
const fetchData = () => {
let url = '../api/traffic/' + props.proxyName
fetch(url, { credentials: 'include' })
.then((res) => {
return res.json()
})
loading.value = true
getProxyTraffic(props.proxyName)
.then((json) => {
DrawProxyTrafficChart(props.proxyName, json.trafficIn, json.trafficOut)
processData(json.trafficIn, json.trafficOut)
})
.catch((err) => {
ElMessage({
showClose: true,
message: 'Get traffic info failed!' + err,
message: 'Get traffic info failed! ' + err,
type: 'warning',
})
})
.finally(() => {
loading.value = false
})
}
fetchData()
onMounted(() => {
fetchData()
})
</script>
<style></style>
<style scoped>
.traffic-chart-container {
width: 100%;
height: 400px;
display: flex;
flex-direction: column;
padding: 20px;
}
.chart-wrapper {
flex: 1;
display: flex;
gap: 10px;
position: relative;
margin-bottom: 20px;
}
.y-axis {
display: flex;
flex-direction: column;
justify-content: space-between;
text-align: right;
font-size: 12px;
color: #909399;
padding-bottom: 24px; /* Align with bars area excluding date labels */
height: calc(100% - 24px); /* Subtract date label height approx */
}
.bars-area {
flex: 1;
display: flex;
justify-content: space-between;
align-items: flex-end;
position: relative;
height: 100%;
padding-bottom: 24px; /* Space for date labels */
}
.grid-line {
position: absolute;
left: 0;
right: 0;
height: 1px;
background-color: #e4e7ed;
z-index: 0;
}
html.dark .grid-line {
background-color: #3a3d5c;
}
.grid-line.top { top: 0; }
.grid-line.middle { top: 50%; transform: translateY(-50%); }
.grid-line.bottom { bottom: 24px; } /* Align with bottom of bars */
.day-column {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
position: relative;
z-index: 1;
}
.bars-group {
height: 100%;
display: flex;
align-items: flex-end;
gap: 4px;
width: 60%;
}
.bar {
flex: 1;
border-radius: 4px 4px 0 0;
transition: height 0.3s ease;
min-height: 1px;
}
.bar-in {
background-color: #5470c6;
}
.bar-out {
background-color: #91cc75;
}
.bar:hover {
opacity: 0.8;
}
.date-label {
position: absolute;
bottom: -24px;
font-size: 12px;
color: #909399;
width: 100%;
text-align: center;
}
.legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 10px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #606266;
}
html.dark .legend-item {
color: #e5e7eb;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.dot.in { background-color: #5470c6; }
.dot.out { background-color: #91cc75; }
</style>

View File

@@ -1,11 +1,10 @@
import { createApp } from 'vue'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import App from './App.vue'
import router from './router'
import './assets/custom.css'
import './assets/dark.css'
import './assets/css/custom.css'
import './assets/css/dark.css'
const app = createApp(App)

View File

@@ -1,12 +1,7 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import ServerOverview from '../components/ServerOverview.vue'
import ProxiesTCP from '../components/ProxiesTCP.vue'
import ProxiesUDP from '../components/ProxiesUDP.vue'
import ProxiesHTTP from '../components/ProxiesHTTP.vue'
import ProxiesHTTPS from '../components/ProxiesHTTPS.vue'
import ProxiesTCPMux from '../components/ProxiesTCPMux.vue'
import ProxiesSTCP from '../components/ProxiesSTCP.vue'
import ProxiesSUDP from '../components/ProxiesSUDP.vue'
import ServerOverview from '../views/ServerOverview.vue'
import Clients from '../views/Clients.vue'
import Proxies from '../views/Proxies.vue'
const router = createRouter({
history: createWebHashHistory(),
@@ -17,39 +12,14 @@ const router = createRouter({
component: ServerOverview,
},
{
path: '/proxies/tcp',
name: 'ProxiesTCP',
component: ProxiesTCP,
path: '/clients',
name: 'Clients',
component: Clients,
},
{
path: '/proxies/udp',
name: 'ProxiesUDP',
component: ProxiesUDP,
},
{
path: '/proxies/http',
name: 'ProxiesHTTP',
component: ProxiesHTTP,
},
{
path: '/proxies/https',
name: 'ProxiesHTTPS',
component: ProxiesHTTPS,
},
{
path: '/proxies/tcpmux',
name: 'ProxiesTCPMux',
component: ProxiesTCPMux,
},
{
path: '/proxies/stcp',
name: 'ProxiesSTCP',
component: ProxiesSTCP,
},
{
path: '/proxies/sudp',
name: 'ProxiesSUDP',
component: ProxiesSUDP,
path: '/proxies/:type?',
name: 'Proxies',
component: Proxies,
},
],
})

5
web/frps/src/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare module '*.svg?component' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@@ -0,0 +1,12 @@
export interface ClientInfoData {
key: string
user: string
clientId: string
runId: string
hostname: string
metas?: Record<string, string>
firstConnectedAt: number
lastConnectedAt: number
disconnectedAt?: number
online: boolean
}

View File

@@ -0,0 +1,21 @@
export interface ProxyStatsInfo {
name: string
conf: any
clientVersion: string
todayTrafficIn: number
todayTrafficOut: number
curConns: number
lastStartTime: string
lastCloseTime: string
status: string
}
export interface GetProxyResponse {
proxies: ProxyStatsInfo[]
}
export interface TrafficResponse {
name: string
trafficIn: number[]
trafficOut: number[]
}

View File

@@ -0,0 +1,22 @@
export interface ServerInfo {
version: string
bindPort: number
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
kcpBindPort: number
quicBindPort: number
subdomainHost: string
maxPoolCount: number
maxPortsPerClient: number
heartbeatTimeout: number
allowPortsStr: string
tlsForce: boolean
// Stats
totalTrafficIn: number
totalTrafficOut: number
curConns: number
clientCounts: number
proxyTypeCount: Record<string, number>
}

View File

@@ -1,293 +0,0 @@
import * as Humanize from 'humanize-plus'
import * as echarts from 'echarts/core'
import { PieChart, BarChart } from 'echarts/charts'
import { CanvasRenderer } from 'echarts/renderers'
import { LabelLayout } from 'echarts/features'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
} from 'echarts/components'
echarts.use([
PieChart,
BarChart,
CanvasRenderer,
LabelLayout,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
])
function DrawTrafficChart(
elementId: string,
trafficIn: number,
trafficOut: number
) {
const myChart = echarts.init(
document.getElementById(elementId) as HTMLElement,
'macarons'
)
myChart.showLoading()
const option = {
title: {
text: 'Network Traffic',
subtext: 'today',
left: 'center',
},
tooltip: {
trigger: 'item',
formatter: function (v: any) {
return Humanize.fileSize(v.data.value) + ' (' + v.percent + '%)'
},
},
legend: {
orient: 'vertical',
left: 'left',
data: ['Traffic In', 'Traffic Out'],
},
series: [
{
type: 'pie',
radius: '55%',
center: ['50%', '60%'],
data: [
{
value: trafficIn,
name: 'Traffic In',
},
{
value: trafficOut,
name: 'Traffic Out',
},
],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
myChart.setOption(option)
myChart.hideLoading()
}
function DrawProxyChart(elementId: string, serverInfo: any) {
const myChart = echarts.init(
document.getElementById(elementId) as HTMLElement,
'macarons'
)
myChart.showLoading()
const option = {
title: {
text: 'Proxies',
subtext: 'now',
left: 'center',
},
tooltip: {
trigger: 'item',
formatter: function (v: any) {
return String(v.data.value)
},
},
legend: {
orient: 'vertical',
left: 'left',
data: <string[]>[],
},
series: [
{
type: 'pie',
radius: '55%',
center: ['50%', '60%'],
data: <any[]>[],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
}
if (
serverInfo.proxyTypeCount.tcp != null &&
serverInfo.proxyTypeCount.tcp != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.tcp,
name: 'TCP',
})
option.legend.data.push('TCP')
}
if (
serverInfo.proxyTypeCount.udp != null &&
serverInfo.proxyTypeCount.udp != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.udp,
name: 'UDP',
})
option.legend.data.push('UDP')
}
if (
serverInfo.proxyTypeCount.http != null &&
serverInfo.proxyTypeCount.http != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.http,
name: 'HTTP',
})
option.legend.data.push('HTTP')
}
if (
serverInfo.proxyTypeCount.https != null &&
serverInfo.proxyTypeCount.https != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.https,
name: 'HTTPS',
})
option.legend.data.push('HTTPS')
}
if (
serverInfo.proxyTypeCount.stcp != null &&
serverInfo.proxyTypeCount.stcp != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.stcp,
name: 'STCP',
})
option.legend.data.push('STCP')
}
if (
serverInfo.proxyTypeCount.sudp != null &&
serverInfo.proxyTypeCount.sudp != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.sudp,
name: 'SUDP',
})
option.legend.data.push('SUDP')
}
if (
serverInfo.proxyTypeCount.xtcp != null &&
serverInfo.proxyTypeCount.xtcp != 0
) {
option.series[0].data.push({
value: serverInfo.proxyTypeCount.xtcp,
name: 'XTCP',
})
option.legend.data.push('XTCP')
}
myChart.setOption(option)
myChart.hideLoading()
}
// 7 days
function DrawProxyTrafficChart(
elementId: string,
trafficInArr: number[],
trafficOutArr: number[]
) {
const params = {
width: '600px',
height: '400px',
}
const myChart = echarts.init(
document.getElementById(elementId) as HTMLElement,
'macarons',
params
)
myChart.showLoading()
trafficInArr = trafficInArr.reverse()
trafficOutArr = trafficOutArr.reverse()
let now = new Date()
now = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6)
const dates: Array<string> = []
for (let i = 0; i < 7; i++) {
dates.push(
now.getFullYear() + '-' + (now.getMonth() + 1) + '-' + now.getDate()
)
now = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
}
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: function (data: any) {
let html = ''
if (data.length > 0) {
html += data[0].name + '<br/>'
}
for (const v of data) {
const colorEl =
'<span style="display:inline-block;margin-right:5px;' +
'border-radius:10px;width:9px;height:9px;background-color:' +
v.color +
'"></span>'
html +=
colorEl + v.seriesName + ': ' + Humanize.fileSize(v.value) + '<br/>'
}
return html
},
},
legend: {
data: ['Traffic In', 'Traffic Out'],
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: [
{
type: 'category',
data: dates,
},
],
yAxis: [
{
type: 'value',
axisLabel: {
formatter: function (value: number) {
return Humanize.fileSize(value)
},
},
},
],
series: [
{
name: 'Traffic In',
type: 'bar',
data: trafficInArr,
},
{
name: 'Traffic Out',
type: 'bar',
data: trafficOutArr,
},
],
}
myChart.setOption(option)
myChart.hideLoading()
}
export { DrawTrafficChart, DrawProxyChart, DrawProxyTrafficChart }

View File

@@ -0,0 +1,82 @@
import { formatDistanceToNow } from './format'
import type { ClientInfoData } from '../types/client'
export class Client {
key: string
user: string
clientId: string
runId: string
hostname: string
metas: Map<string, string>
firstConnectedAt: Date
lastConnectedAt: Date
disconnectedAt?: Date
online: boolean
constructor(data: ClientInfoData) {
this.key = data.key
this.user = data.user
this.clientId = data.clientId
this.runId = data.runId
this.hostname = data.hostname
this.metas = new Map<string, string>()
if (data.metas) {
for (const [key, value] of Object.entries(data.metas)) {
this.metas.set(key, value)
}
}
this.firstConnectedAt = new Date(data.firstConnectedAt * 1000)
this.lastConnectedAt = new Date(data.lastConnectedAt * 1000)
if (data.disconnectedAt && data.disconnectedAt > 0) {
this.disconnectedAt = new Date(data.disconnectedAt * 1000)
}
this.online = data.online
}
get displayName(): string {
if (this.clientId) {
return this.user ? `${this.user}.${this.clientId}` : this.clientId
}
return this.runId
}
get shortRunId(): string {
return this.runId.substring(0, 8)
}
get firstConnectedAgo(): string {
return formatDistanceToNow(this.firstConnectedAt)
}
get lastConnectedAgo(): string {
return formatDistanceToNow(this.lastConnectedAt)
}
get disconnectedAgo(): string {
if (!this.disconnectedAt) return ''
return formatDistanceToNow(this.disconnectedAt)
}
get statusColor(): string {
return this.online ? 'success' : 'danger'
}
get metasArray(): Array<{ key: string; value: string }> {
const arr: Array<{ key: string; value: string }> = []
this.metas.forEach((value, key) => {
arr.push({ key, value })
})
return arr
}
matchesFilter(searchText: string): boolean {
const search = searchText.toLowerCase()
return (
this.key.toLowerCase().includes(search) ||
this.user.toLowerCase().includes(search) ||
this.clientId.toLowerCase().includes(search) ||
this.runId.toLowerCase().includes(search) ||
this.hostname.toLowerCase().includes(search)
)
}
}

View File

@@ -0,0 +1,33 @@
export function formatDistanceToNow(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
let interval = seconds / 31536000
if (interval > 1) return Math.floor(interval) + ' years ago'
interval = seconds / 2592000
if (interval > 1) return Math.floor(interval) + ' months ago'
interval = seconds / 86400
if (interval > 1) return Math.floor(interval) + ' days ago'
interval = seconds / 3600
if (interval > 1) return Math.floor(interval) + ' hours ago'
interval = seconds / 60
if (interval > 1) return Math.floor(interval) + ' minutes ago'
return Math.floor(seconds) + ' seconds ago'
}
export function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
// Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit
}

View File

@@ -0,0 +1,169 @@
<template>
<div class="clients-page">
<div class="filter-bar">
<el-input
v-model="searchText"
placeholder="Search by hostname, user, client ID, run ID..."
:prefix-icon="Search"
clearable
class="search-input"
/>
<el-radio-group v-model="statusFilter" class="status-filter">
<el-radio-button label="all">All ({{ stats.total }})</el-radio-button>
<el-radio-button label="online">
Online ({{ stats.online }})
</el-radio-button>
<el-radio-button label="offline">
Offline ({{ stats.offline }})
</el-radio-button>
</el-radio-group>
</div>
<div v-loading="loading" class="clients-grid">
<el-empty
v-if="filteredClients.length === 0 && !loading"
description="No clients found"
/>
<ClientCard
v-for="client in filteredClients"
:key="client.key"
:client="client"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
import { Client } from '../utils/client'
import ClientCard from '../components/ClientCard.vue'
import { getClients } from '../api/client'
const clients = ref<Client[]>([])
const loading = ref(false)
const searchText = ref('')
const statusFilter = ref<'all' | 'online' | 'offline'>('all')
let refreshTimer: number | null = null
const stats = computed(() => {
const total = clients.value.length
const online = clients.value.filter((c) => c.online).length
const offline = total - online
return { total, online, offline }
})
const filteredClients = computed(() => {
let result = clients.value
// Filter by status
if (statusFilter.value === 'online') {
result = result.filter((c) => c.online)
} else if (statusFilter.value === 'offline') {
result = result.filter((c) => !c.online)
}
// Filter by search text
if (searchText.value) {
result = result.filter((c) => c.matchesFilter(searchText.value))
}
// Sort: online first, then by display name
result.sort((a, b) => {
if (a.online !== b.online) {
return a.online ? -1 : 1
}
return a.displayName.localeCompare(b.displayName)
})
return result
})
const fetchData = async () => {
loading.value = true
try {
const json = await getClients()
clients.value = json.map((data) => new Client(data))
} catch (error: any) {
console.error('Failed to fetch clients:', error)
ElMessage({
showClose: true,
message: 'Failed to fetch clients: ' + error.message,
type: 'error',
})
} finally {
loading.value = false
}
}
const startAutoRefresh = () => {
// Auto refresh every 5 seconds
refreshTimer = window.setInterval(() => {
fetchData()
}, 5000)
}
const stopAutoRefresh = () => {
if (refreshTimer !== null) {
window.clearInterval(refreshTimer)
refreshTimer = null
}
}
onMounted(() => {
fetchData()
startAutoRefresh()
})
onUnmounted(() => {
stopAutoRefresh()
})
</script>
<style scoped>
.clients-page {
padding: 0 20px 20px 20px;
}
.filter-bar {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 20px;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 300px;
max-width: 500px;
}
.status-filter {
flex-shrink: 0;
}
.clients-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
min-height: 200px;
}
@media (max-width: 768px) {
.clients-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
}
.search-input {
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,375 @@
<template>
<div class="proxies-page">
<!-- Main Content -->
<el-card class="main-card" shadow="never">
<div class="toolbar-header">
<el-tabs v-model="activeType" class="proxy-tabs">
<el-tab-pane
v-for="t in proxyTypes"
:key="t.value"
:label="t.label"
:name="t.value"
/>
</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
title="Are you sure to clear all data of offline proxies?"
@confirm="clearOfflineProxies"
>
<template #reference>
<el-button type="danger" plain :icon="Delete"
>Clear Offline</el-button
>
</template>
</el-popconfirm>
</div>
</div>
<el-table
v-loading="loading"
:data="filteredProxies"
:default-sort="{ prop: 'name', order: 'ascending' }"
style="width: 100%"
>
<el-table-column type="expand">
<template #default="props">
<div class="expand-wrapper">
<ProxyViewExpand :row="props.row" :proxyType="activeType" />
</div>
</template>
</el-table-column>
<el-table-column
label="Name"
prop="name"
sortable
min-width="150"
show-overflow-tooltip
/>
<el-table-column label="Port" prop="port" sortable width="100" />
<el-table-column
label="Conns"
prop="conns"
sortable
width="100"
align="center"
/>
<el-table-column label="Traffic" width="220">
<template #default="scope">
<div class="traffic-cell">
<span class="traffic-item up" title="Traffic Out">
<el-icon><Top /></el-icon>
{{ formatFileSize(scope.row.trafficOut) }}
</span>
<span class="traffic-item down" title="Traffic In">
<el-icon><Bottom /></el-icon>
{{ formatFileSize(scope.row.trafficIn) }}
</span>
</div>
</template>
</el-table-column>
<el-table-column
label="Version"
prop="clientVersion"
sortable
width="140"
show-overflow-tooltip
/>
<el-table-column
label="Status"
prop="status"
sortable
width="120"
align="center"
>
<template #default="scope">
<el-tag
:type="scope.row.status === 'online' ? 'success' : 'danger'"
effect="light"
round
>
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column
label="Action"
width="120"
align="center"
fixed="right"
>
<template #default="scope">
<el-button
type="primary"
link
:icon="DataAnalysis"
@click="showTraffic(scope.row.name)"
>
Traffic
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog
v-model="dialogVisible"
destroy-on-close
:title="`Traffic Statistics - ${dialogVisibleName}`"
width="700px"
align-center
class="traffic-dialog"
>
<Traffic :proxyName="dialogVisibleName" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { formatFileSize } from '../utils/format'
import { ElMessage } from 'element-plus'
import {
Search,
Refresh,
Delete,
Top,
Bottom,
DataAnalysis,
} from '@element-plus/icons-vue'
import {
BaseProxy,
TCPProxy,
UDPProxy,
HTTPProxy,
HTTPSProxy,
TCPMuxProxy,
STCPProxy,
SUDPProxy,
} from '../utils/proxy'
import ProxyViewExpand from '../components/ProxyViewExpand.vue'
import Traffic from '../components/Traffic.vue'
import { getProxiesByType, clearOfflineProxies as apiClearOfflineProxies } from '../api/proxy'
import { getServerInfo } from '../api/server'
const route = useRoute()
const router = useRouter()
const proxyTypes = [
{ label: 'TCP', value: 'tcp' },
{ label: 'UDP', value: 'udp' },
{ label: 'HTTP', value: 'http' },
{ label: 'HTTPS', value: 'https' },
{ label: 'TCPMUX', value: 'tcpmux' },
{ label: 'STCP', value: 'stcp' },
{ label: 'SUDP', value: 'sudp' },
]
const activeType = ref((route.params.type as string) || 'tcp')
const proxies = ref<BaseProxy[]>([])
const loading = ref(false)
const searchText = ref('')
const dialogVisible = ref(false)
const dialogVisibleName = ref('')
const filteredProxies = computed(() => {
if (!searchText.value) {
return proxies.value
}
const search = searchText.value.toLowerCase()
return proxies.value.filter((p) => p.name.toLowerCase().includes(search))
})
// Server info cache
let serverInfo: {
vhostHTTPPort: number
vhostHTTPSPort: number
tcpmuxHTTPConnectPort: number
subdomainHost: string
} | null = null
const fetchServerInfo = async () => {
if (serverInfo) return serverInfo
const res = await getServerInfo()
serverInfo = res
return serverInfo
}
const fetchData = async () => {
loading.value = true
proxies.value = []
try {
const type = activeType.value
const json = await getProxiesByType(type)
if (type === 'tcp') {
proxies.value = json.proxies.map((p: any) => new TCPProxy(p))
} else if (type === 'udp') {
proxies.value = json.proxies.map((p: any) => new UDPProxy(p))
} else if (type === 'http') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPPort) {
proxies.value = json.proxies.map(
(p: any) => new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
)
}
} else if (type === 'https') {
const info = await fetchServerInfo()
if (info && info.vhostHTTPSPort) {
proxies.value = json.proxies.map(
(p: any) =>
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
)
}
} else if (type === 'tcpmux') {
const info = await fetchServerInfo()
if (info && info.tcpmuxHTTPConnectPort) {
proxies.value = json.proxies.map(
(p: any) =>
new TCPMuxProxy(p, info.tcpmuxHTTPConnectPort, info.subdomainHost),
)
}
} else if (type === 'stcp') {
proxies.value = json.proxies.map((p: any) => new STCPProxy(p))
} else if (type === 'sudp') {
proxies.value = json.proxies.map((p: any) => new SUDPProxy(p))
}
} catch (error: any) {
console.error('Failed to fetch proxies:', error)
ElMessage({
showClose: true,
message: 'Failed to fetch proxies: ' + error.message,
type: 'error',
})
} finally {
loading.value = false
}
}
const showTraffic = (name: string) => {
dialogVisibleName.value = name
dialogVisible.value = true
}
const clearOfflineProxies = async () => {
try {
await apiClearOfflineProxies()
ElMessage({
message: 'Successfully cleared offline proxies',
type: 'success',
})
fetchData()
} catch (err: any) {
ElMessage({
message: 'Failed to clear offline proxies: ' + err.message,
type: 'warning',
})
}
}
// Watch for type changes
watch(activeType, (newType) => {
router.replace({ params: { type: newType } })
fetchData()
})
// Initial fetch
fetchData()
</script>
<style scoped>
.proxies-page {
padding: 24px;
max-width: 1600px;
margin: 0 auto;
}
/* Main Content */
.main-card {
border-radius: 12px;
border: none;
}
.toolbar-header {
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;
}
.proxy-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
.proxy-tabs :deep(.el-tabs__nav-wrap::after) {
height: 0;
}
.toolbar-actions {
display: flex;
gap: 12px;
align-items: center;
}
.search-input {
width: 240px;
}
/* Table Styling */
.traffic-cell {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.traffic-item {
display: flex;
align-items: center;
gap: 4px;
}
.traffic-item.up {
color: #67c23a;
}
.traffic-item.down {
color: #409eff;
}
.expand-wrapper {
padding: 16px 24px;
background-color: transparent;
}
/* Responsive */
@media (max-width: 768px) {
.toolbar-header {
flex-direction: column;
align-items: stretch;
}
.toolbar-actions {
justify-content: space-between;
}
.search-input {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,457 @@
<template>
<div class="server-overview">
<el-row :gutter="20" class="stats-row">
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Clients"
:value="data.clientCounts"
type="clients"
subtitle="Connected clients"
to="/clients"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Proxies"
:value="data.proxyCounts"
type="proxies"
subtitle="Active proxies"
to="/proxies/tcp"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Connections"
:value="data.curConns"
type="connections"
subtitle="Current connections"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Traffic"
:value="formatTrafficTotal()"
type="traffic"
subtitle="Total today"
/>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :xs="24" :md="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Network Traffic</span>
<el-tag size="small" type="info">Today</el-tag>
</div>
</template>
<div class="traffic-summary">
<div class="traffic-item in">
<div class="traffic-icon">
<el-icon><Download /></el-icon>
</div>
<div class="traffic-info">
<div class="label">Inbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div>
</div>
</div>
<div class="traffic-divider"></div>
<div class="traffic-item out">
<div class="traffic-icon">
<el-icon><Upload /></el-icon>
</div>
<div class="traffic-info">
<div class="label">Outbound</div>
<div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Proxy Types</span>
<el-tag size="small" type="info">Now</el-tag>
</div>
</template>
<div class="proxy-types-grid">
<div
v-for="(count, type) in data.proxyTypeCounts"
:key="type"
class="proxy-type-item"
v-show="count > 0"
>
<div class="proxy-type-name">{{ type.toUpperCase() }}</div>
<div class="proxy-type-count">{{ count }}</div>
</div>
<div v-if="!hasActiveProxies" class="no-data">
No active proxies
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="config-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Server Configuration</span>
<el-tag size="small" type="success">v{{ data.version }}</el-tag>
</div>
</template>
<div class="config-grid">
<div class="config-item">
<span class="config-label">Bind Port</span>
<span class="config-value">{{ data.bindPort }}</span>
</div>
<div class="config-item" v-if="data.kcpBindPort != 0">
<span class="config-label">KCP Port</span>
<span class="config-value">{{ data.kcpBindPort }}</span>
</div>
<div class="config-item" v-if="data.quicBindPort != 0">
<span class="config-label">QUIC Port</span>
<span class="config-value">{{ data.quicBindPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPPort != 0">
<span class="config-label">HTTP Port</span>
<span class="config-value">{{ data.vhostHTTPPort }}</span>
</div>
<div class="config-item" v-if="data.vhostHTTPSPort != 0">
<span class="config-label">HTTPS Port</span>
<span class="config-value">{{ data.vhostHTTPSPort }}</span>
</div>
<div class="config-item" v-if="data.tcpmuxHTTPConnectPort != 0">
<span class="config-label">TCPMux Port</span>
<span class="config-value">{{ data.tcpmuxHTTPConnectPort }}</span>
</div>
<div class="config-item" v-if="data.subdomainHost != ''">
<span class="config-label">Subdomain Host</span>
<span class="config-value">{{ data.subdomainHost }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Pool Count</span>
<span class="config-value">{{ data.maxPoolCount }}</span>
</div>
<div class="config-item">
<span class="config-label">Max Ports/Client</span>
<span class="config-value">{{ data.maxPortsPerClient }}</span>
</div>
<div class="config-item" v-if="data.allowPortsStr != ''">
<span class="config-label">Allow Ports</span>
<span class="config-value">{{ data.allowPortsStr }}</span>
</div>
<div class="config-item" v-if="data.tlsForce">
<span class="config-label">TLS Force</span>
<el-tag size="small" type="warning">Enabled</el-tag>
</div>
<div class="config-item">
<span class="config-label">Heartbeat Timeout</span>
<span class="config-value">{{ data.heartbeatTimeout }}s</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { formatFileSize } from '../utils/format'
import { Download, Upload } from '@element-plus/icons-vue'
import StatCard from '../components/StatCard.vue'
import { getServerInfo } from '../api/server'
const data = ref({
version: '',
bindPort: 0,
kcpBindPort: 0,
quicBindPort: 0,
vhostHTTPPort: 0,
vhostHTTPSPort: 0,
tcpmuxHTTPConnectPort: 0,
subdomainHost: '',
maxPoolCount: 0,
maxPortsPerClient: '',
allowPortsStr: '',
tlsForce: false,
heartbeatTimeout: 0,
clientCounts: 0,
curConns: 0,
proxyCounts: 0,
totalTrafficIn: 0,
totalTrafficOut: 0,
proxyTypeCounts: {} as Record<string, number>,
})
const hasActiveProxies = computed(() => {
return Object.values(data.value.proxyTypeCounts).some(c => c > 0)
})
const formatTrafficTotal = () => {
const total = data.value.totalTrafficIn + data.value.totalTrafficOut
return formatFileSize(total)
}
const fetchData = async () => {
try {
const json = await getServerInfo()
data.value.version = json.version
data.value.bindPort = json.bindPort
data.value.kcpBindPort = json.kcpBindPort
data.value.quicBindPort = json.quicBindPort
data.value.vhostHTTPPort = json.vhostHTTPPort
data.value.vhostHTTPSPort = json.vhostHTTPSPort
data.value.tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
data.value.subdomainHost = json.subdomainHost
data.value.maxPoolCount = json.maxPoolCount
data.value.maxPortsPerClient = String(json.maxPortsPerClient)
if (data.value.maxPortsPerClient == '0') {
data.value.maxPortsPerClient = 'no limit'
}
data.value.allowPortsStr = json.allowPortsStr
data.value.tlsForce = json.tlsForce
data.value.heartbeatTimeout = json.heartbeatTimeout
data.value.clientCounts = json.clientCounts
data.value.curConns = json.curConns
data.value.totalTrafficIn = json.totalTrafficIn
data.value.totalTrafficOut = json.totalTrafficOut
data.value.proxyTypeCounts = json.proxyTypeCount || {}
data.value.proxyCounts = 0
if (json.proxyTypeCount != null) {
Object.values(json.proxyTypeCount).forEach((count: any) => {
data.value.proxyCounts += (count || 0)
})
}
} catch (err) {
ElMessage({
showClose: true,
message: 'Get server info from frps failed!',
type: 'error',
})
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.server-overview {
padding: 0;
}
.stats-row {
margin-bottom: 20px;
}
.charts-row {
margin-bottom: 20px;
}
.chart-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
height: 100%;
}
html.dark .chart-card {
border-color: #3a3d5c;
background: #27293d;
}
.config-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
margin-bottom: 20px;
}
html.dark .config-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
html.dark .card-title {
color: #e5e7eb;
}
.traffic-summary {
display: flex;
align-items: center;
justify-content: space-around;
min-height: 120px;
padding: 10px 0;
}
.traffic-item {
display: flex;
align-items: center;
gap: 16px;
}
.traffic-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.traffic-item.in .traffic-icon {
background: rgba(84, 112, 198, 0.1);
color: #5470c6;
}
.traffic-item.out .traffic-icon {
background: rgba(145, 204, 117, 0.1);
color: #91cc75;
}
.traffic-info {
display: flex;
flex-direction: column;
}
.traffic-info .label {
font-size: 14px;
color: #909399;
}
.traffic-info .value {
font-size: 24px;
font-weight: 600;
color: #303133;
}
html.dark .traffic-info .value {
color: #e5e7eb;
}
.traffic-divider {
width: 1px;
height: 60px;
background: #e4e7ed;
}
html.dark .traffic-divider {
background: #3a3d5c;
}
.proxy-types-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 16px;
min-height: 120px;
align-content: center;
padding: 10px 0;
}
.proxy-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
.proxy-type-name {
font-size: 12px;
color: #909399;
font-weight: 500;
margin-bottom: 4px;
}
.proxy-type-count {
font-size: 20px;
font-weight: 600;
color: #303133;
}
html.dark .proxy-type-count {
color: #e5e7eb;
}
.no-data {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #909399;
font-size: 14px;
}
.config-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
}
.config-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
transition: background 0.2s;
}
html.dark .config-item {
background: #1e1e2d;
}
.config-label {
font-size: 12px;
color: #909399;
font-weight: 500;
}
html.dark .config-label {
color: #9ca3af;
}
.config-value {
font-size: 14px;
color: #303133;
font-weight: 600;
word-break: break-all;
}
html.dark .config-value {
color: #e5e7eb;
}
@media (max-width: 768px) {
.chart-container {
height: 250px;
}
.config-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -2,15 +2,19 @@ import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import svgLoader from 'vite-svg-loader'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import ElementPlus from 'unplugin-element-plus/vite'
// https://vitejs.dev/config/
export default defineConfig({
base: '',
plugins: [
vue(),
svgLoader(),
ElementPlus({}),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
@@ -25,5 +29,21 @@ export default defineConfig({
},
build: {
assetsDir: '',
chunkSizeWarningLimit: 1000,
minify: 'terser',
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
},
},
},
server: {
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',
changeOrigin: true,
},
},
},
})

File diff suppressed because it is too large Load Diff