mirror of
https://github.com/fatedier/frp.git
synced 2026-07-21 02:59:18 +08:00
Compare commits
3 Commits
33428ab538
...
a4175a2595
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4175a2595 | ||
|
|
36718d88e4 | ||
|
|
bc378bcbec |
19
README.md
19
README.md
@@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -49,15 +59,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## What is frp?
|
||||
|
||||
19
README_zh.md
19
README_zh.md
@@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
||||
|
||||
<h3 align="center">Gold Sponsors</h3>
|
||||
<!--gold sponsors start-->
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://jb.gg/frp" target="_blank">
|
||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||
@@ -50,15 +60,6 @@ an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and
|
||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://go.warp.dev/frp" target="_blank">
|
||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
||||
<br>
|
||||
<b>Warp, built for collaborating with AI Agents</b>
|
||||
<br>
|
||||
<sub>Available for macOS, Linux and Windows</sub>
|
||||
</a>
|
||||
</p>
|
||||
<!--gold sponsors end-->
|
||||
|
||||
## 为什么使用 frp ?
|
||||
|
||||
13
Release.md
13
Release.md
@@ -1,13 +1,4 @@
|
||||
## Features
|
||||
|
||||
* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities.
|
||||
* Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load.
|
||||
* OIDC authentication now supports a `tokenSource` field to dynamically obtain tokens from external sources. You can use `type = "file"` to read a token from a file, or `type = "exec"` to run an external command (e.g., a cloud CLI or secrets manager) and capture its stdout as the token. The `exec` type requires the `--allow-unsafe=TokenSourceExec` CLI flag for security reasons.
|
||||
|
||||
## Improvements
|
||||
|
||||
* **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals.
|
||||
|
||||
## Fixes
|
||||
|
||||
* Fix deadlock issue when TCP connection is closed. Previously, sending messages could block forever if the connection handler had already stopped.
|
||||
* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
|
||||
* Redesigned the frps web dashboard with a modern UI, dark mode support, and improved navigation.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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
1
assets/frps/static/index-BUrDiw1t.js
Normal file
1
assets/frps/static/index-BUrDiw1t.js
Normal file
File diff suppressed because one or more lines are too long
1
assets/frps/static/index-D4KRVvIu.css
Normal file
1
assets/frps/static/index-D4KRVvIu.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
146
server/client_registry.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ module.exports = {
|
||||
'vue/multi-word-component-names': [
|
||||
'error',
|
||||
{
|
||||
ignores: ['Traffic'],
|
||||
ignores: ['Traffic', 'Proxies', 'Clients'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
25
web/frps/components.d.ts
vendored
25
web/frps/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>frps dashboard</title>
|
||||
<title>frp server</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
web/frps/src/api/client.ts
Normal file
10
web/frps/src/api/client.ts
Normal 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
50
web/frps/src/api/http.ts
Normal 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
18
web/frps/src/api/proxy.ts
Normal 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')
|
||||
}
|
||||
6
web/frps/src/api/server.ts
Normal file
6
web/frps/src/api/server.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { http } from './http'
|
||||
import type { ServerInfo } from '../types/server'
|
||||
|
||||
export const getServerInfo = () => {
|
||||
return http.get<ServerInfo>('../api/serverinfo')
|
||||
}
|
||||
89
web/frps/src/assets/css/custom.css
Normal file
89
web/frps/src/assets/css/custom.css
Normal 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;
|
||||
}
|
||||
}
|
||||
58
web/frps/src/assets/css/dark.css
Normal file
58
web/frps/src/assets/css/dark.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
html.dark {
|
||||
--el-bg-color: #343432;
|
||||
--el-fill-color-blank: #343432;
|
||||
background-color: #343432;
|
||||
}
|
||||
3
web/frps/src/assets/icons/github.svg
Normal file
3
web/frps/src/assets/icons/github.svg
Normal 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 |
229
web/frps/src/components/ClientCard.vue
Normal file
229
web/frps/src/components/ClientCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
202
web/frps/src/components/StatCard.vue
Normal file
202
web/frps/src/components/StatCard.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
5
web/frps/src/svg.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module '*.svg?component' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<object, object, unknown>
|
||||
export default component
|
||||
}
|
||||
12
web/frps/src/types/client.ts
Normal file
12
web/frps/src/types/client.ts
Normal 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
|
||||
}
|
||||
21
web/frps/src/types/proxy.ts
Normal file
21
web/frps/src/types/proxy.ts
Normal 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[]
|
||||
}
|
||||
22
web/frps/src/types/server.ts
Normal file
22
web/frps/src/types/server.ts
Normal 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>
|
||||
}
|
||||
@@ -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 }
|
||||
82
web/frps/src/utils/client.ts
Normal file
82
web/frps/src/utils/client.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
33
web/frps/src/utils/format.ts
Normal file
33
web/frps/src/utils/format.ts
Normal 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
|
||||
}
|
||||
@@ -128,7 +128,7 @@ class TCPMuxProxy extends BaseProxy {
|
||||
if (proxyStats.conf.subdomain) {
|
||||
this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
web/frps/src/views/Clients.vue
Normal file
169
web/frps/src/views/Clients.vue
Normal 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>
|
||||
375
web/frps/src/views/Proxies.vue
Normal file
375
web/frps/src/views/Proxies.vue
Normal 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>
|
||||
457
web/frps/src/views/ServerOverview.vue
Normal file
457
web/frps/src/views/ServerOverview.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
2071
web/frps/yarn.lock
2071
web/frps/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user