mirror of
https://github.com/fatedier/frp.git
synced 2026-04-22 17:09:10 +08:00
Compare commits
9 Commits
b94a0caea6
...
v0.67.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f575b8442 | ||
|
|
7a1c248b67 | ||
|
|
886c9c2fdb | ||
|
|
266c492b5d | ||
|
|
5dd70ace6b | ||
|
|
a1348cdf00 | ||
|
|
2f5e1f7945 | ||
|
|
22ae8166d3 | ||
|
|
af6bc6369d |
@@ -6,6 +6,14 @@ jobs:
|
||||
resource_class: large
|
||||
steps:
|
||||
- checkout
|
||||
- run:
|
||||
name: Build web assets (frps)
|
||||
command: make install build
|
||||
working_directory: web/frps
|
||||
- run:
|
||||
name: Build web assets (frpc)
|
||||
command: make install build
|
||||
working_directory: web/frpc
|
||||
- run: make
|
||||
- run: make alltest
|
||||
|
||||
|
||||
9
.github/workflows/golangci-lint.yml
vendored
9
.github/workflows/golangci-lint.yml
vendored
@@ -19,6 +19,15 @@ jobs:
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
run: make install build
|
||||
working-directory: web/frps
|
||||
- name: Build web assets (frpc)
|
||||
run: make install build
|
||||
working-directory: web/frpc
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
|
||||
12
.github/workflows/goreleaser.yml
vendored
12
.github/workflows/goreleaser.yml
vendored
@@ -16,13 +16,21 @@ jobs:
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Build web assets (frps)
|
||||
run: make install build
|
||||
working-directory: web/frps
|
||||
- name: Build web assets (frpc)
|
||||
run: make install build
|
||||
working-directory: web/frpc
|
||||
- name: Make All
|
||||
run: |
|
||||
./package.sh
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
version: latest
|
||||
args: release --clean --release-notes=./Release.md
|
||||
|
||||
21
Makefile
21
Makefile
@@ -2,19 +2,22 @@ export PATH := $(PATH):`go env GOPATH`/bin
|
||||
export GO111MODULE=on
|
||||
LDFLAGS := -s -w
|
||||
|
||||
all: env fmt build
|
||||
.PHONY: web frps-web frpc-web frps frpc
|
||||
|
||||
all: env fmt web build
|
||||
|
||||
build: frps frpc
|
||||
|
||||
env:
|
||||
@go version
|
||||
|
||||
# compile assets into binary file
|
||||
file:
|
||||
rm -rf ./assets/frps/static/*
|
||||
rm -rf ./assets/frpc/static/*
|
||||
cp -rf ./web/frps/dist/* ./assets/frps/static
|
||||
cp -rf ./web/frpc/dist/* ./assets/frpc/static
|
||||
web: frps-web frpc-web
|
||||
|
||||
frps-web:
|
||||
$(MAKE) -C web/frps build
|
||||
|
||||
frpc-web:
|
||||
$(MAKE) -C web/frpc build
|
||||
|
||||
fmt:
|
||||
go fmt ./...
|
||||
@@ -25,7 +28,7 @@ fmt-more:
|
||||
gci:
|
||||
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
|
||||
|
||||
vet:
|
||||
vet: web
|
||||
go vet ./...
|
||||
|
||||
frps:
|
||||
@@ -36,7 +39,7 @@ frpc:
|
||||
|
||||
test: gotest
|
||||
|
||||
gotest:
|
||||
gotest: web
|
||||
go test -v --cover ./assets/...
|
||||
go test -v --cover ./cmd/...
|
||||
go test -v --cover ./client/...
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## Features
|
||||
|
||||
* frpc now supports a `clientID` option to uniquely identify client instances. The server dashboard displays all connected clients with their online/offline status, connection history, and metadata, making it easier to monitor and manage multiple frpc deployments.
|
||||
* Redesigned the frps web dashboard with a modern UI, dark mode support, and improved navigation.
|
||||
* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
|
||||
|
||||
## Fixes
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ func Load(path string) {
|
||||
}
|
||||
|
||||
func Register(fileSystem fs.FS) {
|
||||
subFs, err := fs.Sub(fileSystem, "static")
|
||||
subFs, err := fs.Sub(fileSystem, "dist")
|
||||
if err == nil {
|
||||
content = subFs
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>frp client</title>
|
||||
<script type="module" crossorigin src="./index-BAsh6RH1.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./index-JCcyRUo1.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
package frpc
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/fatedier/frp/assets"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var content embed.FS
|
||||
|
||||
func init() {
|
||||
assets.Register(content)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>frp server</title>
|
||||
<script type="module" crossorigin src="./index-r9B2t7lx.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./index-Cl4R6mJh.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -15,9 +15,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/fatedier/frp/assets/frpc"
|
||||
"github.com/fatedier/frp/cmd/frpc/sub"
|
||||
"github.com/fatedier/frp/pkg/util/system"
|
||||
_ "github.com/fatedier/frp/web/frpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "github.com/fatedier/frp/assets/frps"
|
||||
_ "github.com/fatedier/frp/pkg/metrics"
|
||||
"github.com/fatedier/frp/pkg/util/system"
|
||||
_ "github.com/fatedier/frp/web/frps"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
WORKDIR /web/frpc
|
||||
COPY web/frpc/ ./
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.24 AS building
|
||||
|
||||
COPY . /building
|
||||
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||
WORKDIR /building
|
||||
|
||||
RUN make frpc
|
||||
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frpc -o bin/frpc ./cmd/frpc
|
||||
|
||||
FROM alpine:3
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
FROM node:22 AS web-builder
|
||||
|
||||
WORKDIR /web/frps
|
||||
COPY web/frps/ ./
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
FROM golang:1.24 AS building
|
||||
|
||||
COPY . /building
|
||||
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||
WORKDIR /building
|
||||
|
||||
RUN make frps
|
||||
RUN env CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -tags frps -o bin/frps ./cmd/frps
|
||||
|
||||
FROM alpine:3
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
|
||||
for _, v := range m.ms {
|
||||
v.NewProxy(name, proxyType)
|
||||
v.NewProxy(name, proxyType, user, clientID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() {
|
||||
m.info.ClientCounts.Dec(1)
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
counter, ok := m.info.ProxyTypeCounts[proxyType]
|
||||
@@ -119,6 +119,8 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
||||
}
|
||||
m.info.ProxyStatistics[name] = proxyStats
|
||||
}
|
||||
proxyStats.User = user
|
||||
proxyStats.ClientID = clientID
|
||||
proxyStats.LastStartTime = time.Now()
|
||||
}
|
||||
|
||||
@@ -214,6 +216,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
||||
ps := &ProxyStats{
|
||||
Name: name,
|
||||
Type: proxyStats.ProxyType,
|
||||
User: proxyStats.User,
|
||||
ClientID: proxyStats.ClientID,
|
||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||
CurConns: int64(proxyStats.CurConns.Count()),
|
||||
@@ -245,6 +249,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
||||
res = &ProxyStats{
|
||||
Name: name,
|
||||
Type: proxyStats.ProxyType,
|
||||
User: proxyStats.User,
|
||||
ClientID: proxyStats.ClientID,
|
||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||
CurConns: int64(proxyStats.CurConns.Count()),
|
||||
@@ -260,6 +266,31 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
||||
return
|
||||
}
|
||||
|
||||
func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||
if ok {
|
||||
res = &ProxyStats{
|
||||
Name: proxyName,
|
||||
Type: proxyStats.ProxyType,
|
||||
User: proxyStats.User,
|
||||
ClientID: proxyStats.ClientID,
|
||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||
CurConns: int64(proxyStats.CurConns.Count()),
|
||||
}
|
||||
if !proxyStats.LastStartTime.IsZero() {
|
||||
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||
}
|
||||
if !proxyStats.LastCloseTime.IsZero() {
|
||||
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -35,6 +35,8 @@ type ServerStats struct {
|
||||
type ProxyStats struct {
|
||||
Name string
|
||||
Type string
|
||||
User string
|
||||
ClientID string
|
||||
TodayTrafficIn int64
|
||||
TodayTrafficOut int64
|
||||
LastStartTime string
|
||||
@@ -51,6 +53,8 @@ type ProxyTrafficInfo struct {
|
||||
type ProxyStatistics struct {
|
||||
Name string
|
||||
ProxyType string
|
||||
User string
|
||||
ClientID string
|
||||
TrafficIn metric.DateCounter
|
||||
TrafficOut metric.DateCounter
|
||||
CurConns metric.Counter
|
||||
@@ -78,6 +82,7 @@ type Collector interface {
|
||||
GetServer() *ServerStats
|
||||
GetProxiesByType(proxyType string) []*ProxyStats
|
||||
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
|
||||
GetProxyByName(proxyName string) *ProxyStats
|
||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||
ClearOfflineProxies() (int, int)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() {
|
||||
m.clientCount.Dec()
|
||||
}
|
||||
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
||||
func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {
|
||||
m.proxyCount.WithLabelValues(proxyType).Inc()
|
||||
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
package version
|
||||
|
||||
var version = "0.66.0"
|
||||
var version = "0.67.0"
|
||||
|
||||
func Full() string {
|
||||
return version
|
||||
|
||||
@@ -117,7 +117,7 @@ func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
|
||||
if userFilter != "" && info.User != userFilter {
|
||||
continue
|
||||
}
|
||||
if clientIDFilter != "" && info.ClientID != clientIDFilter {
|
||||
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
||||
continue
|
||||
}
|
||||
if runIDFilter != "" && info.RunID != runIDFilter {
|
||||
@@ -204,6 +204,48 @@ func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||
return trafficResp, nil
|
||||
}
|
||||
|
||||
// /api/proxies/:name
|
||||
func (c *Controller) APIProxyByName(ctx *httppkg.Context) (any, error) {
|
||||
name := ctx.Param("name")
|
||||
|
||||
ps := mem.StatsCollector.GetProxyByName(name)
|
||||
if ps == nil {
|
||||
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||
}
|
||||
|
||||
proxyInfo := GetProxyStatsResp{
|
||||
Name: ps.Name,
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
TodayTrafficIn: ps.TodayTrafficIn,
|
||||
TodayTrafficOut: ps.TodayTrafficOut,
|
||||
CurConns: ps.CurConns,
|
||||
LastStartTime: ps.LastStartTime,
|
||||
LastCloseTime: ps.LastCloseTime,
|
||||
}
|
||||
|
||||
if pxy, ok := c.pxyManager.GetByName(name); ok {
|
||||
content, err := json.Marshal(pxy.GetConfigurer())
|
||||
if err != nil {
|
||||
log.Warnf("marshal proxy [%s] conf info error: %v", name, err)
|
||||
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
|
||||
}
|
||||
proxyInfo.Conf = getConfByType(ps.Type)
|
||||
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
||||
log.Warnf("unmarshal proxy [%s] conf info error: %v", name, err)
|
||||
return nil, httppkg.NewError(http.StatusBadRequest, "parse conf error")
|
||||
}
|
||||
proxyInfo.Status = "online"
|
||||
c.fillProxyClientInfo(&proxyClientInfo{
|
||||
clientVersion: &proxyInfo.ClientVersion,
|
||||
}, pxy)
|
||||
} else {
|
||||
proxyInfo.Status = "offline"
|
||||
}
|
||||
|
||||
return proxyInfo, nil
|
||||
}
|
||||
|
||||
// DELETE /api/proxies?status=offline
|
||||
func (c *Controller) DeleteProxies(ctx *httppkg.Context) (any, error) {
|
||||
status := ctx.Query("status")
|
||||
@@ -219,7 +261,10 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
|
||||
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
|
||||
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
|
||||
for _, ps := range proxyStats {
|
||||
proxyInfo := &ProxyStatsInfo{}
|
||||
proxyInfo := &ProxyStatsInfo{
|
||||
User: ps.User,
|
||||
ClientID: ps.ClientID,
|
||||
}
|
||||
if pxy, ok := c.pxyManager.GetByName(ps.Name); ok {
|
||||
content, err := json.Marshal(pxy.GetConfigurer())
|
||||
if err != nil {
|
||||
@@ -232,9 +277,9 @@ func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyS
|
||||
continue
|
||||
}
|
||||
proxyInfo.Status = "online"
|
||||
if pxy.GetLoginMsg() != nil {
|
||||
proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
|
||||
}
|
||||
c.fillProxyClientInfo(&proxyClientInfo{
|
||||
clientVersion: &proxyInfo.ClientVersion,
|
||||
}, pxy)
|
||||
} else {
|
||||
proxyInfo.Status = "offline"
|
||||
}
|
||||
@@ -256,6 +301,8 @@ func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName stri
|
||||
code = 404
|
||||
msg = "no proxy info found"
|
||||
} else {
|
||||
proxyInfo.User = ps.User
|
||||
proxyInfo.ClientID = ps.ClientID
|
||||
if pxy, ok := c.pxyManager.GetByName(proxyName); ok {
|
||||
content, err := json.Marshal(pxy.GetConfigurer())
|
||||
if err != nil {
|
||||
@@ -290,7 +337,7 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
||||
resp := ClientInfoResp{
|
||||
Key: info.Key,
|
||||
User: info.User,
|
||||
ClientID: info.ClientID,
|
||||
ClientID: info.ClientID(),
|
||||
RunID: info.RunID,
|
||||
Hostname: info.Hostname,
|
||||
ClientIP: info.IP,
|
||||
@@ -304,6 +351,37 @@ func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
||||
return resp
|
||||
}
|
||||
|
||||
type proxyClientInfo struct {
|
||||
user *string
|
||||
clientID *string
|
||||
clientVersion *string
|
||||
}
|
||||
|
||||
func (c *Controller) fillProxyClientInfo(proxyInfo *proxyClientInfo, pxy proxy.Proxy) {
|
||||
loginMsg := pxy.GetLoginMsg()
|
||||
if loginMsg == nil {
|
||||
return
|
||||
}
|
||||
if proxyInfo.user != nil {
|
||||
*proxyInfo.user = loginMsg.User
|
||||
}
|
||||
if proxyInfo.clientVersion != nil {
|
||||
*proxyInfo.clientVersion = loginMsg.Version
|
||||
}
|
||||
if info, ok := c.clientRegistry.GetByRunID(loginMsg.RunID); ok {
|
||||
if proxyInfo.clientID != nil {
|
||||
*proxyInfo.clientID = info.ClientID()
|
||||
}
|
||||
return
|
||||
}
|
||||
if proxyInfo.clientID != nil {
|
||||
*proxyInfo.clientID = loginMsg.ClientID
|
||||
if *proxyInfo.clientID == "" {
|
||||
*proxyInfo.clientID = loginMsg.RunID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toUnix(t time.Time) int64 {
|
||||
if t.IsZero() {
|
||||
return 0
|
||||
|
||||
@@ -98,6 +98,8 @@ type XTCPOutConf struct {
|
||||
type ProxyStatsInfo struct {
|
||||
Name string `json:"name"`
|
||||
Conf any `json:"conf"`
|
||||
User string `json:"user,omitempty"`
|
||||
ClientID string `json:"clientID,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
@@ -115,6 +117,9 @@ type GetProxyInfoResp struct {
|
||||
type GetProxyStatsResp struct {
|
||||
Name string `json:"name"`
|
||||
Conf any `json:"conf"`
|
||||
User string `json:"user,omitempty"`
|
||||
ClientID string `json:"clientID,omitempty"`
|
||||
ClientVersion string `json:"clientVersion,omitempty"`
|
||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
||||
CurConns int64 `json:"curConns"`
|
||||
|
||||
@@ -405,7 +405,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
|
||||
} else {
|
||||
resp.RemoteAddr = remoteAddr
|
||||
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
||||
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
|
||||
clientID := ctl.loginMsg.ClientID
|
||||
if clientID == "" {
|
||||
clientID = ctl.loginMsg.RunID
|
||||
}
|
||||
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
|
||||
}
|
||||
_ = ctl.msgDispatcher.Send(resp)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
type ServerMetrics interface {
|
||||
NewClient()
|
||||
CloseClient()
|
||||
NewProxy(name string, proxyType string)
|
||||
NewProxy(name string, proxyType string, user string, clientID string)
|
||||
CloseProxy(name string, proxyType string)
|
||||
OpenConnection(name string, proxyType string)
|
||||
CloseConnection(name string, proxyType string)
|
||||
@@ -27,11 +27,11 @@ func Register(m ServerMetrics) {
|
||||
|
||||
type noopServerMetrics struct{}
|
||||
|
||||
func (noopServerMetrics) NewClient() {}
|
||||
func (noopServerMetrics) CloseClient() {}
|
||||
func (noopServerMetrics) NewProxy(string, string) {}
|
||||
func (noopServerMetrics) CloseProxy(string, string) {}
|
||||
func (noopServerMetrics) OpenConnection(string, string) {}
|
||||
func (noopServerMetrics) CloseConnection(string, string) {}
|
||||
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
|
||||
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
|
||||
func (noopServerMetrics) NewClient() {}
|
||||
func (noopServerMetrics) CloseClient() {}
|
||||
func (noopServerMetrics) NewProxy(string, string, string, string) {}
|
||||
func (noopServerMetrics) CloseProxy(string, string) {}
|
||||
func (noopServerMetrics) OpenConnection(string, string) {}
|
||||
func (noopServerMetrics) CloseConnection(string, string) {}
|
||||
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
|
||||
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
type ClientInfo struct {
|
||||
Key string
|
||||
User string
|
||||
ClientID string
|
||||
RawClientID string
|
||||
RunID string
|
||||
Hostname string
|
||||
IP string
|
||||
@@ -34,8 +34,8 @@ type ClientInfo struct {
|
||||
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.
|
||||
// ClientRegistry keeps track of active clients keyed by "{user}.{clientID}" (runID fallback when raw clientID is empty).
|
||||
// Entries without an explicit raw clientID are removed on disconnect to avoid stale offline records.
|
||||
type ClientRegistry struct {
|
||||
mu sync.RWMutex
|
||||
clients map[string]*ClientInfo
|
||||
@@ -50,17 +50,17 @@ func NewClientRegistry() *ClientRegistry {
|
||||
}
|
||||
|
||||
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||
func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
|
||||
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
|
||||
if runID == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
effectiveID := clientID
|
||||
effectiveID := rawClientID
|
||||
if effectiveID == "" {
|
||||
effectiveID = runID
|
||||
}
|
||||
key = cr.composeClientKey(user, effectiveID)
|
||||
enforceUnique := clientID != ""
|
||||
enforceUnique := rawClientID != ""
|
||||
|
||||
now := time.Now()
|
||||
cr.mu.Lock()
|
||||
@@ -75,7 +75,6 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s
|
||||
info = &ClientInfo{
|
||||
Key: key,
|
||||
User: user,
|
||||
ClientID: clientID,
|
||||
FirstConnectedAt: now,
|
||||
}
|
||||
cr.clients[key] = info
|
||||
@@ -83,6 +82,7 @@ func (cr *ClientRegistry) Register(user, clientID, runID, hostname, remoteAddr s
|
||||
delete(cr.runIndex, info.RunID)
|
||||
}
|
||||
|
||||
info.RawClientID = rawClientID
|
||||
info.RunID = runID
|
||||
info.Hostname = hostname
|
||||
info.IP = remoteAddr
|
||||
@@ -107,7 +107,7 @@ func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||
return
|
||||
}
|
||||
if info, ok := cr.clients[key]; ok && info.RunID == runID {
|
||||
if info.ClientID == "" {
|
||||
if info.RawClientID == "" {
|
||||
delete(cr.clients, key)
|
||||
} else {
|
||||
info.RunID = ""
|
||||
@@ -131,7 +131,7 @@ func (cr *ClientRegistry) List() []ClientInfo {
|
||||
return result
|
||||
}
|
||||
|
||||
// GetByKey retrieves a client by its composite key ({user}.{clientID} or runID fallback).
|
||||
// GetByKey retrieves a client by its composite key ({user}.{clientID} with runID fallback).
|
||||
func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
|
||||
cr.mu.RLock()
|
||||
defer cr.mu.RUnlock()
|
||||
@@ -143,6 +143,30 @@ func (cr *ClientRegistry) GetByKey(key string) (ClientInfo, bool) {
|
||||
return *info, true
|
||||
}
|
||||
|
||||
// ClientID returns the resolved client identifier for external use.
|
||||
func (info ClientInfo) ClientID() string {
|
||||
if info.RawClientID != "" {
|
||||
return info.RawClientID
|
||||
}
|
||||
return info.RunID
|
||||
}
|
||||
|
||||
// GetByRunID retrieves a client by its run ID.
|
||||
func (cr *ClientRegistry) GetByRunID(runID string) (ClientInfo, bool) {
|
||||
cr.mu.RLock()
|
||||
defer cr.mu.RUnlock()
|
||||
|
||||
key, ok := cr.runIndex[runID]
|
||||
if !ok {
|
||||
return ClientInfo{}, false
|
||||
}
|
||||
info, ok := cr.clients[key]
|
||||
if !ok {
|
||||
return ClientInfo{}, false
|
||||
}
|
||||
return *info, true
|
||||
}
|
||||
|
||||
func (cr *ClientRegistry) composeClientKey(user, id string) string {
|
||||
switch {
|
||||
case user == "":
|
||||
|
||||
@@ -99,7 +99,7 @@ type Service struct {
|
||||
// Manage all controllers
|
||||
ctlManager *ControlManager
|
||||
|
||||
// Track logical clients keyed by user.clientID.
|
||||
// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
|
||||
clientRegistry *registry.ClientRegistry
|
||||
|
||||
// Manage all proxies
|
||||
@@ -709,6 +709,7 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
||||
subRouter.HandleFunc("/api/serverinfo", httppkg.MakeHTTPHandlerFunc(apiController.APIServerInfo)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/proxy/{type}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByType)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/proxy/{type}/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByTypeAndName)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/proxies/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyByName)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/traffic/{name}", httppkg.MakeHTTPHandlerFunc(apiController.APIProxyTraffic)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/clients", httppkg.MakeHTTPHandlerFunc(apiController.APIClientList)).Methods("GET")
|
||||
subRouter.HandleFunc("/api/clients/{key}", httppkg.MakeHTTPHandlerFunc(apiController.APIClientDetail)).Methods("GET")
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.PHONY: dist build preview lint
|
||||
.PHONY: dist install build preview lint
|
||||
|
||||
install:
|
||||
@npm install
|
||||
|
||||
build:
|
||||
@npm run build
|
||||
|
||||
10
web/frpc/components.d.ts
vendored
10
web/frpc/components.d.ts
vendored
@@ -9,16 +9,18 @@ declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
||||
}
|
||||
export interface ComponentCustomProperties {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
|
||||
@@ -6,9 +6,9 @@ import (
|
||||
"github.com/fatedier/frp/assets"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var content embed.FS
|
||||
//go:embed dist
|
||||
var EmbedFS embed.FS
|
||||
|
||||
func init() {
|
||||
assets.Register(content)
|
||||
assets.Register(EmbedFS)
|
||||
}
|
||||
5857
web/frpc/package-lock.json
generated
Normal file
5857
web/frpc/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,42 +1,50 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<header class="header">
|
||||
<div class="header-top">
|
||||
<div class="brand">
|
||||
<a href="#" @click.prevent="router.push('/')">frpc</a>
|
||||
<div class="header-content">
|
||||
<div class="header-top">
|
||||
<div class="brand-section">
|
||||
<div class="logo-wrapper">
|
||||
<LogoIcon class="logo-icon" />
|
||||
</div>
|
||||
<span class="divider">/</span>
|
||||
<span class="brand-name">frp</span>
|
||||
<span class="badge client-badge">Client</span>
|
||||
<span class="badge" v-if="currentRouteName">{{
|
||||
currentRouteName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="isDark"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
|
||||
<nav class="nav-bar">
|
||||
<router-link to="/" class="nav-link" active-class="active"
|
||||
>Overview</router-link
|
||||
>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="darkmodeSwitch"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
@change="toggleDark"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</div>
|
||||
<router-link to="/configure" class="nav-link" active-class="active"
|
||||
>Configure</router-link
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<el-menu
|
||||
:default-active="currentRoute"
|
||||
mode="horizontal"
|
||||
:ellipsis="false"
|
||||
@select="handleSelect"
|
||||
class="nav-menu"
|
||||
>
|
||||
<el-menu-item index="/">Overview</el-menu-item>
|
||||
<el-menu-item index="/configure">Configure</el-menu-item>
|
||||
</el-menu>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
@@ -44,252 +52,214 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import GitHubIcon from './assets/icons/github.svg?component'
|
||||
import LogoIcon from './assets/icons/logo.svg?component'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isDark = useDark()
|
||||
const darkmodeSwitch = ref(isDark)
|
||||
const toggleDark = useToggle(isDark)
|
||||
|
||||
const currentRoute = computed(() => {
|
||||
return route.path
|
||||
const currentRouteName = computed(() => {
|
||||
if (route.path === '/') return 'Overview'
|
||||
if (route.path === '/configure') return 'Configure'
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--header-height: 112px;
|
||||
--header-bg: rgba(255, 255, 255, 0.8);
|
||||
--header-border: #eaeaea;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--hover-bg: #f5f5f5;
|
||||
--active-link: #000;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--header-bg: rgba(0, 0, 0, 0.8);
|
||||
--header-border: #333;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #888;
|
||||
--hover-bg: #1a1a1a;
|
||||
--active-link: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Helvetica Neue,
|
||||
sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
html.dark #app {
|
||||
background: #1a1a2e;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .header {
|
||||
background: #1e1e2d;
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
color: #303133;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
.brand-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--header-border);
|
||||
font-size: 24px;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
html.dark .brand a {
|
||||
color: #e5e7eb;
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #409eff;
|
||||
.badge.client-badge {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
html.dark .badge.client-badge {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
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;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #606266;
|
||||
transition: color 0.2s;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.github-link:hover .github-icon {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .github-icon {
|
||||
color: #a0a3ad;
|
||||
}
|
||||
|
||||
html.dark .github-link:hover .github-icon {
|
||||
color: #e5e7eb;
|
||||
.github-link:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--header-border);
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
--el-switch-on-color: #2c2c3a;
|
||||
--el-switch-off-color: #f2f2f2;
|
||||
--el-switch-border-color: #dcdfe6;
|
||||
--el-switch-border-color: var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .theme-switch {
|
||||
--el-switch-off-color: #333;
|
||||
}
|
||||
|
||||
.theme-switch .el-switch__core .el-switch__inner .el-icon {
|
||||
color: #909399 !important;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: relative;
|
||||
padding: 0 32px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
.nav-bar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
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;
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 0 12px !important;
|
||||
margin: 7px 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-menu > .el-menu-item,
|
||||
.nav-menu > .el-sub-menu {
|
||||
margin-right: 4px;
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
.nav-link.active {
|
||||
color: var(--active-link);
|
||||
border-bottom-color: var(--active-link);
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
padding: 24px 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-top {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
padding: 0 16px;
|
||||
.header-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-size: 18px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,11 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
|
||||
throw new HTTPError(
|
||||
response.status,
|
||||
response.statusText,
|
||||
`HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle empty response (e.g. 204 No Content)
|
||||
@@ -35,42 +39,54 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }),
|
||||
get: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'GET' }),
|
||||
post: <T>(url: string, body?: any, options?: RequestInit) => {
|
||||
const headers: HeadersInit = { ...options?.headers }
|
||||
let requestBody = body
|
||||
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
|
||||
if (!('Content-Type' in headers)) {
|
||||
(headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
requestBody = JSON.stringify(body)
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof Blob)
|
||||
) {
|
||||
if (!('Content-Type' in headers)) {
|
||||
;(headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return request<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
return request<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: requestBody
|
||||
body: requestBody,
|
||||
})
|
||||
},
|
||||
put: <T>(url: string, body?: any, options?: RequestInit) => {
|
||||
const headers: HeadersInit = { ...options?.headers }
|
||||
let requestBody = body
|
||||
|
||||
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) {
|
||||
if (!('Content-Type' in headers)) {
|
||||
(headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
requestBody = JSON.stringify(body)
|
||||
if (
|
||||
body &&
|
||||
typeof body === 'object' &&
|
||||
!(body instanceof FormData) &&
|
||||
!(body instanceof Blob)
|
||||
) {
|
||||
if (!('Content-Type' in headers)) {
|
||||
;(headers as any)['Content-Type'] = 'application/json'
|
||||
}
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
return request<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
return request<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: requestBody
|
||||
body: requestBody,
|
||||
})
|
||||
},
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
}
|
||||
delete: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'DELETE' }),
|
||||
}
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
.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 */
|
||||
/* Modern Base Styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
/* Smooth transitions for Element Plus components */
|
||||
.el-button,
|
||||
.el-card,
|
||||
.el-input,
|
||||
@@ -37,7 +14,7 @@
|
||||
|
||||
/* Card hover effects */
|
||||
.el-card:hover {
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Better scrollbar */
|
||||
@@ -60,16 +37,6 @@
|
||||
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;
|
||||
@@ -87,3 +54,52 @@
|
||||
padding-right: 10px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input enhancements */
|
||||
.el-input__wrapper {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
|
||||
}
|
||||
|
||||
/* Button enhancements */
|
||||
.el-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Tag enhancements */
|
||||
.el-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Card enhancements */
|
||||
.el-card__header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Table enhancements */
|
||||
.el-table {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.el-table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.el-empty__description {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.el-loading-mask {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
/* Dark Mode Theme */
|
||||
html.dark {
|
||||
--el-bg-color: #1e1e2e;
|
||||
--el-bg-color-page: #1a1a2e;
|
||||
--el-bg-color-overlay: #27293d;
|
||||
--el-fill-color-blank: #1e1e2e;
|
||||
background-color: #1e1e2e;
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
background-color: #1e1e2e;
|
||||
background-color: #1a1a2e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@@ -28,23 +31,50 @@ html.dark .el-card {
|
||||
border-color: #3a3d5c;
|
||||
}
|
||||
|
||||
html.dark .el-card__header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
}
|
||||
|
||||
/* Dark mode inputs */
|
||||
html.dark .el-input__wrapper {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
box-shadow: 0 0 0 1px #3a3d5c inset;
|
||||
}
|
||||
|
||||
html.dark .el-input__wrapper:hover {
|
||||
box-shadow: 0 0 0 1px #4a4d6c inset;
|
||||
}
|
||||
|
||||
html.dark .el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
|
||||
}
|
||||
|
||||
html.dark .el-input__inner {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-input__inner::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode textarea */
|
||||
html.dark .el-textarea__inner {
|
||||
background-color: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-textarea__inner::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode table */
|
||||
html.dark .el-table {
|
||||
background-color: #27293d;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-table th {
|
||||
html.dark .el-table th.el-table__cell {
|
||||
background-color: #1e1e2e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
@@ -53,6 +83,98 @@ html.dark .el-table tr {
|
||||
background-color: #27293d;
|
||||
}
|
||||
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
|
||||
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
|
||||
background-color: #1e1e2e;
|
||||
}
|
||||
|
||||
html.dark .el-table__row:hover > td.el-table__cell {
|
||||
background-color: #2a2a3c !important;
|
||||
}
|
||||
|
||||
/* Dark mode tags */
|
||||
html.dark .el-tag--info {
|
||||
background-color: #3a3d5c;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode buttons */
|
||||
html.dark .el-button--default {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-button--default:hover {
|
||||
background-color: #2a2a3c;
|
||||
border-color: #4a4d6c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Dark mode select */
|
||||
html.dark .el-select .el-input__wrapper {
|
||||
background-color: #27293d;
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown__item {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-select-dropdown__item:hover {
|
||||
background-color: #2a2a3c;
|
||||
}
|
||||
|
||||
/* Dark mode dialog */
|
||||
html.dark .el-dialog {
|
||||
background-color: #27293d;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__header {
|
||||
border-bottom-color: #3a3d5c;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-dialog__body {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode message box */
|
||||
html.dark .el-message-box {
|
||||
background-color: #27293d;
|
||||
border-color: #3a3d5c;
|
||||
}
|
||||
|
||||
html.dark .el-message-box__title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
html.dark .el-message-box__message {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode empty */
|
||||
html.dark .el-empty__description {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Dark mode loading */
|
||||
html.dark .el-loading-mask {
|
||||
background-color: rgba(30, 30, 46, 0.9);
|
||||
}
|
||||
|
||||
html.dark .el-loading-text {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Dark mode tooltip */
|
||||
html.dark .el-tooltip__trigger {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
15
web/frpc/src/assets/icons/logo.svg
Normal file
15
web/frpc/src/assets/icons/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
|
||||
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
|
||||
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
|
||||
<path
|
||||
d="M37 28 V72
|
||||
M37 28 H63
|
||||
M37 50 H55"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="14"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
236
web/frpc/src/components/ProxyCard.vue
Normal file
236
web/frpc/src/components/ProxyCard.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
|
||||
<div class="card-main">
|
||||
<div class="card-left">
|
||||
<div class="card-header">
|
||||
<span class="proxy-name">{{ proxy.name }}</span>
|
||||
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="proxy.local_addr" class="meta-item">
|
||||
<span class="meta-label">Local:</span>
|
||||
<span class="meta-value code">{{ proxy.local_addr }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.plugin" class="meta-item">
|
||||
<span class="meta-label">Plugin:</span>
|
||||
<span class="meta-value code">{{ proxy.plugin }}</span>
|
||||
</span>
|
||||
<span v-if="proxy.remote_addr" class="meta-item">
|
||||
<span class="meta-label">Remote:</span>
|
||||
<span class="meta-value code">{{ proxy.remote_addr }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-right">
|
||||
<div v-if="proxy.err" class="error-info">
|
||||
<el-icon class="error-icon"><Warning /></el-icon>
|
||||
<span class="error-text">{{ proxy.err }}</span>
|
||||
</div>
|
||||
<div class="status-badge" :class="statusClass">
|
||||
{{ proxy.status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Warning } from '@element-plus/icons-vue'
|
||||
import type { ProxyStatus } from '../types/proxy'
|
||||
|
||||
interface Props {
|
||||
proxy: ProxyStatus
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.proxy.status) {
|
||||
case 'running':
|
||||
return 'running'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'waiting'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-card {
|
||||
display: block;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.proxy-card:hover {
|
||||
border-color: var(--el-border-color-light);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.proxy-card.has-error {
|
||||
border-color: var(--el-color-danger-light-5);
|
||||
}
|
||||
|
||||
html.dark .proxy-card.has-error {
|
||||
border-color: var(--el-color-danger-dark-2);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Left Section */
|
||||
.card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.meta-value.code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
background: var(--el-fill-color-light);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Right Section */
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--el-color-danger);
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 12px;
|
||||
color: var(--el-color-danger);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.running {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-badge.error {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.status-badge.waiting {
|
||||
background: var(--el-color-warning-light-9);
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
202
web/frpc/src/components/StatCard.vue
Normal file
202
web/frpc/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 {
|
||||
Connection,
|
||||
CircleCheck,
|
||||
Warning,
|
||||
Setting,
|
||||
ArrowRight,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
value: string | number
|
||||
type?: 'proxies' | 'running' | 'error' | 'config'
|
||||
subtitle?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'proxies',
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'proxies':
|
||||
return Connection
|
||||
case 'running':
|
||||
return CircleCheck
|
||||
case 'error':
|
||||
return Warning
|
||||
case 'config':
|
||||
return Setting
|
||||
default:
|
||||
return Connection
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.to) {
|
||||
router.push(props.to)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.stat-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-card.clickable:hover .arrow-icon {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
html.dark .stat-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.stat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
color: #909399;
|
||||
font-size: 18px;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html.dark .arrow-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.icon-proxies {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-running {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-error {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.icon-config {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
html.dark .icon-proxies {
|
||||
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-running {
|
||||
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-error {
|
||||
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
|
||||
}
|
||||
|
||||
html.dark .icon-config {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
html.dark .stat-value {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
html.dark .stat-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-subtitle {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #e4e7ed;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
html.dark .stat-subtitle {
|
||||
border-top-color: #3a3d5c;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -10,4 +10,4 @@ const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
app.mount('#app')
|
||||
|
||||
@@ -18,4 +18,4 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export interface ProxyStatus {
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
err: string
|
||||
local_addr: string
|
||||
plugin: string
|
||||
remote_addr: string
|
||||
[key: string]: any
|
||||
name: string
|
||||
type: string
|
||||
status: string
|
||||
err: string
|
||||
local_addr: string
|
||||
plugin: string
|
||||
remote_addr: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export type StatusResponse = Record<string, ProxyStatus[]>
|
||||
|
||||
@@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,33 +1,120 @@
|
||||
<template>
|
||||
<div class="configure-page">
|
||||
<el-card class="main-card" shadow="never">
|
||||
<div class="toolbar-header">
|
||||
<h2 class="card-title">Client Configuration</h2>
|
||||
<div class="toolbar-actions">
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
<el-button type="primary" :icon="Upload" @click="handleUpload">Update</el-button>
|
||||
</div>
|
||||
<div class="page-header">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Configuration</h1>
|
||||
<p class="page-subtitle">
|
||||
Edit and manage your frpc configuration file
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-editor">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 10, maxRows: 30 }"
|
||||
v-model="configContent"
|
||||
placeholder="frpc configuration file content..."
|
||||
class="code-input"
|
||||
></el-input>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card class="editor-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Configuration Editor</span>
|
||||
<el-tag size="small" type="success">TOML</el-tag>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
<el-button type="primary" :icon="Upload" @click="handleUpload">
|
||||
Update & Reload
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="editor-wrapper">
|
||||
<el-input
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 20, maxRows: 40 }"
|
||||
v-model="configContent"
|
||||
placeholder="# frpc configuration file content...
|
||||
|
||||
[common]
|
||||
server_addr = 127.0.0.1
|
||||
server_port = 7000"
|
||||
class="code-editor"
|
||||
></el-input>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card class="help-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Quick Reference</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="help-content">
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Common Settings</h4>
|
||||
<div class="help-items">
|
||||
<div class="help-item">
|
||||
<code>serverAddr</code>
|
||||
<span>Server address</span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<code>serverPort</code>
|
||||
<span>Server port (default: 7000)</span>
|
||||
</div>
|
||||
<div class="help-item">
|
||||
<code>auth.token</code>
|
||||
<span>Authentication token</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Proxy Types</h4>
|
||||
<div class="proxy-type-tags">
|
||||
<el-tag type="primary" effect="plain">TCP</el-tag>
|
||||
<el-tag type="success" effect="plain">UDP</el-tag>
|
||||
<el-tag type="warning" effect="plain">HTTP</el-tag>
|
||||
<el-tag type="danger" effect="plain">HTTPS</el-tag>
|
||||
<el-tag type="info" effect="plain">STCP</el-tag>
|
||||
<el-tag effect="plain">XTCP</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h4 class="help-section-title">Example Proxy</h4>
|
||||
<pre class="code-example">
|
||||
[[proxies]]
|
||||
name = "web"
|
||||
type = "http"
|
||||
localPort = 80
|
||||
customDomains = ["example.com"]</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<a
|
||||
href="https://github.com/fatedier/frp#configuration-files"
|
||||
target="_blank"
|
||||
class="docs-link"
|
||||
>
|
||||
<el-icon><Link /></el-icon>
|
||||
View Full Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Refresh, Upload } from '@element-plus/icons-vue'
|
||||
import { Refresh, Upload, Link } from '@element-plus/icons-vue'
|
||||
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
||||
|
||||
const configContent = ref('')
|
||||
@@ -53,7 +140,7 @@ const handleUpload = () => {
|
||||
confirmButtonText: 'Update',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning',
|
||||
}
|
||||
},
|
||||
)
|
||||
.then(async () => {
|
||||
if (!configContent.value.trim()) {
|
||||
@@ -80,7 +167,7 @@ const handleUpload = () => {
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// cancelled
|
||||
// cancelled
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,28 +175,227 @@ fetchData()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
.configure-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.editor-card,
|
||||
.help-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
html.dark .editor-card,
|
||||
html.dark .help-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
padding-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
html.dark .card-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
||||
.editor-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-editor :deep(.el-textarea__inner) {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e4e7ed;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
html.dark .code-editor :deep(.el-textarea__inner) {
|
||||
background: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.code-editor :deep(.el-textarea__inner:focus) {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
/* Help Card */
|
||||
.help-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.help-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.help-section-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.help-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.help-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
html.dark .help-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
.help-item code {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.help-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.proxy-type-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-example {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e4e7ed;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
html.dark .code-example {
|
||||
background: #1e1e2d;
|
||||
border-color: #3a3d5c;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.docs-link:hover {
|
||||
background: var(--el-color-primary-light-8);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.help-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,92 +1,132 @@
|
||||
<template>
|
||||
<div class="overview-page">
|
||||
<el-card class="main-card" shadow="never">
|
||||
<div class="toolbar-header">
|
||||
<h2 class="card-title">Proxy Status</h2>
|
||||
<div class="toolbar-actions">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Total Proxies"
|
||||
:value="stats.total"
|
||||
type="proxies"
|
||||
subtitle="Configured proxies"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Running"
|
||||
:value="stats.running"
|
||||
type="running"
|
||||
subtitle="Active connections"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Error"
|
||||
:value="stats.error"
|
||||
type="error"
|
||||
subtitle="Failed proxies"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<StatCard
|
||||
label="Configure"
|
||||
value="Edit"
|
||||
type="config"
|
||||
subtitle="Manage settings"
|
||||
to="/configure"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="filteredStatus"
|
||||
:default-sort="{ prop: 'name', order: 'ascending' }"
|
||||
stripe
|
||||
style="width: 100%"
|
||||
class="proxy-table"
|
||||
>
|
||||
<el-table-column
|
||||
prop="name"
|
||||
label="Name"
|
||||
sortable
|
||||
min-width="120"
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
prop="type"
|
||||
label="Type"
|
||||
width="100"
|
||||
sortable
|
||||
>
|
||||
<template #default="scope">
|
||||
<span class="type-text">{{ scope.row.type }}</span>
|
||||
<el-row :gutter="20" class="content-row">
|
||||
<el-col :xs="24" :lg="16">
|
||||
<el-card class="proxy-list-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="header-left">
|
||||
<span class="card-title">Proxy Status</span>
|
||||
<el-tag size="small" type="info"
|
||||
>{{ stats.total }} proxies</el-tag
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
<el-tooltip content="Refresh" placement="top">
|
||||
<el-button :icon="Refresh" circle @click="fetchData" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="local_addr"
|
||||
label="Local Address"
|
||||
min-width="150"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
prop="plugin"
|
||||
label="Plugin"
|
||||
width="120"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
prop="remote_addr"
|
||||
label="Remote Address"
|
||||
min-width="150"
|
||||
sortable
|
||||
show-overflow-tooltip
|
||||
></el-table-column>
|
||||
<el-table-column
|
||||
prop="status"
|
||||
label="Status"
|
||||
width="120"
|
||||
sortable
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">
|
||||
<el-tag
|
||||
:type="getStatusColor(scope.row.status)"
|
||||
effect="light"
|
||||
round
|
||||
|
||||
<div v-loading="loading" class="proxy-list-content">
|
||||
<div v-if="filteredStatus.length > 0" class="proxy-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in filteredStatus"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<el-empty description="No proxies found" />
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card class="types-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Proxy Types</span>
|
||||
<el-tag size="small" type="info">Distribution</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="proxy-types-grid">
|
||||
<div
|
||||
v-for="(count, type) in proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
v-show="count > 0"
|
||||
>
|
||||
{{ scope.row.status }}
|
||||
</el-tag>
|
||||
<div class="proxy-type-name">
|
||||
{{ String(type).toUpperCase() }}
|
||||
</div>
|
||||
<div class="proxy-type-count">{{ count }}</div>
|
||||
</div>
|
||||
<div v-if="!hasActiveProxies" class="no-data">No proxy data</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="status-summary-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="card-title">Status Summary</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="err" label="Info" min-width="150" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.err" class="error-text">{{ scope.row.err }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="status-list">
|
||||
<div class="status-item">
|
||||
<div class="status-indicator running"></div>
|
||||
<span class="status-name">Running</span>
|
||||
<span class="status-count">{{ stats.running }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-indicator waiting"></div>
|
||||
<span class="status-name">Waiting</span>
|
||||
<span class="status-count">{{ stats.waiting }}</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<div class="status-indicator error"></div>
|
||||
<span class="status-name">Error</span>
|
||||
<span class="status-count">{{ stats.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -96,11 +136,33 @@ import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getStatus } from '../api/frpc'
|
||||
import type { ProxyStatus } from '../types/proxy'
|
||||
import StatCard from '../components/StatCard.vue'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
|
||||
const status = ref<ProxyStatus[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = status.value.length
|
||||
const running = status.value.filter((p) => p.status === 'running').length
|
||||
const error = status.value.filter((p) => p.status === 'error').length
|
||||
const waiting = total - running - error
|
||||
return { total, running, error, waiting }
|
||||
})
|
||||
|
||||
const proxyTypeCounts = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
status.value.forEach((p) => {
|
||||
counts[p.type] = (counts[p.type] || 0) + 1
|
||||
})
|
||||
return counts
|
||||
})
|
||||
|
||||
const hasActiveProxies = computed(() => {
|
||||
return status.value.length > 0
|
||||
})
|
||||
|
||||
const filteredStatus = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return status.value
|
||||
@@ -111,28 +173,16 @@ const filteredStatus = computed(() => {
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search) ||
|
||||
p.local_addr.toLowerCase().includes(search) ||
|
||||
p.remote_addr.toLowerCase().includes(search)
|
||||
p.remote_addr.toLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const json = await getStatus()
|
||||
status.value = []
|
||||
for (const key in json) {
|
||||
// json[key] is generic array, we assume it matches ProxyStatus
|
||||
for (const ps of json[key]) {
|
||||
status.value.push(ps)
|
||||
}
|
||||
@@ -153,63 +203,240 @@ fetchData()
|
||||
|
||||
<style scoped>
|
||||
.overview-page {
|
||||
/* No special padding needed if App.vue handles content padding */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.main-card {
|
||||
.stats-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stats-row .el-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.content-row .el-col {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.proxy-list-card,
|
||||
.types-card,
|
||||
.status-summary-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
border: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
html.dark .proxy-list-card,
|
||||
html.dark .types-card,
|
||||
html.dark .status-summary-card {
|
||||
border-color: #3a3d5c;
|
||||
background: #27293d;
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
.status-summary-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-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;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .card-title {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: var(--el-color-danger);
|
||||
.proxy-list-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.type-text {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
.proxy-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
/* Proxy Types Grid */
|
||||
.proxy-types-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||||
gap: 12px;
|
||||
min-height: 80px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.proxy-type-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.proxy-type-item:hover {
|
||||
background: #f0f2f5;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
html.dark .proxy-type-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.proxy-type-name {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.proxy-type-count {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .proxy-type-count {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Status Summary */
|
||||
.status-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-item:hover {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
html.dark .status-item {
|
||||
background: #1e1e2d;
|
||||
}
|
||||
|
||||
html.dark .status-item:hover {
|
||||
background: #2a2a3c;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-indicator.running {
|
||||
background: var(--el-color-success);
|
||||
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
|
||||
}
|
||||
|
||||
.status-indicator.waiting {
|
||||
background: var(--el-color-warning);
|
||||
box-shadow: 0 0 0 3px var(--el-color-warning-light-8);
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: var(--el-color-danger);
|
||||
box-shadow: 0 0 0 3px var(--el-color-danger-light-8);
|
||||
}
|
||||
|
||||
.status-name {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-count {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.toolbar-header {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
|
||||
.header-left {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.proxy-types-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.status-summary-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,7 +39,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
|
||||
allowedHosts: process.env.ALLOWED_HOSTS
|
||||
? process.env.ALLOWED_HOSTS.split(',')
|
||||
: [],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',
|
||||
@@ -47,4 +49,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
2975
web/frpc/yarn.lock
2975
web/frpc/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,7 @@
|
||||
.PHONY: dist build preview lint
|
||||
.PHONY: dist install build preview lint
|
||||
|
||||
install:
|
||||
@npm install
|
||||
|
||||
build:
|
||||
@npm run build
|
||||
|
||||
17
web/frps/components.d.ts
vendored
17
web/frps/components.d.ts
vendored
@@ -11,28 +11,17 @@ declare module 'vue' {
|
||||
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']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
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']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
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']
|
||||
ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.vue')['default']
|
||||
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
||||
|
||||
14
web/frps/embed.go
Normal file
14
web/frps/embed.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package frps
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"github.com/fatedier/frp/assets"
|
||||
)
|
||||
|
||||
//go:embed dist
|
||||
var EmbedFS embed.FS
|
||||
|
||||
func init() {
|
||||
assets.Register(EmbedFS)
|
||||
}
|
||||
6654
web/frps/package-lock.json
generated
Normal file
6654
web/frps/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,43 +1,56 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<header class="header">
|
||||
<div class="header-top">
|
||||
<div class="brand">
|
||||
<a href="#" @click.prevent="router.push('/')">frp</a>
|
||||
<div class="header-content">
|
||||
<div class="header-top">
|
||||
<div class="brand-section">
|
||||
<div class="logo-wrapper">
|
||||
<LogoIcon class="logo-icon" />
|
||||
</div>
|
||||
<span class="divider">/</span>
|
||||
<span class="brand-name">frp</span>
|
||||
<span class="badge server-badge">Server</span>
|
||||
<span class="badge" v-if="currentRouteName">{{
|
||||
currentRouteName
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="header-controls">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="isDark"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a
|
||||
class="github-link"
|
||||
href="https://github.com/fatedier/frp"
|
||||
target="_blank"
|
||||
aria-label="GitHub"
|
||||
|
||||
<nav class="nav-bar">
|
||||
<router-link to="/" class="nav-link" active-class="active"
|
||||
>Overview</router-link
|
||||
>
|
||||
<GitHubIcon class="github-icon" />
|
||||
</a>
|
||||
<el-switch
|
||||
v-model="darkmodeSwitch"
|
||||
inline-prompt
|
||||
:active-icon="Moon"
|
||||
:inactive-icon="Sunny"
|
||||
@change="toggleDark"
|
||||
class="theme-switch"
|
||||
/>
|
||||
</div>
|
||||
<router-link to="/clients" class="nav-link" active-class="active"
|
||||
>Clients</router-link
|
||||
>
|
||||
<router-link
|
||||
to="/proxies"
|
||||
class="nav-link"
|
||||
:class="{ active: route.path.startsWith('/proxies') }"
|
||||
>Proxies</router-link
|
||||
>
|
||||
</nav>
|
||||
</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>
|
||||
|
||||
<main id="content">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
@@ -45,256 +58,215 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useDark } from '@vueuse/core'
|
||||
import { Moon, Sunny } from '@element-plus/icons-vue'
|
||||
import GitHubIcon from './assets/icons/github.svg?component'
|
||||
import LogoIcon from './assets/icons/logo.svg?component'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const isDark = useDark()
|
||||
const darkmodeSwitch = ref(isDark)
|
||||
const toggleDark = useToggle(isDark)
|
||||
|
||||
const currentRoute = computed(() => {
|
||||
// Normalize /proxies/:type to /proxies for menu highlighting
|
||||
if (route.path.startsWith('/proxies')) {
|
||||
return '/proxies'
|
||||
}
|
||||
return route.path
|
||||
const currentRouteName = computed(() => {
|
||||
if (route.path === '/') return 'Overview'
|
||||
if (route.path.startsWith('/clients')) return 'Clients'
|
||||
if (route.path.startsWith('/proxies')) return 'Proxies'
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSelect = (key: string) => {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--header-height: 112px;
|
||||
--header-bg: rgba(255, 255, 255, 0.8);
|
||||
--header-border: #eaeaea;
|
||||
--text-primary: #000;
|
||||
--text-secondary: #666;
|
||||
--hover-bg: #f5f5f5;
|
||||
--active-link: #000;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--header-bg: rgba(0, 0, 0, 0.8);
|
||||
--header-border: #333;
|
||||
--text-primary: #fff;
|
||||
--text-secondary: #888;
|
||||
--hover-bg: #1a1a1a;
|
||||
--active-link: #fff;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Helvetica Neue,
|
||||
sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, sans-serif;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f2f2f2;
|
||||
}
|
||||
|
||||
html.dark #app {
|
||||
background: #1a1a2e;
|
||||
background-color: var(--el-bg-color-page);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .header {
|
||||
background: #1e1e2d;
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
color: #303133;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
.brand-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
color: var(--header-border);
|
||||
font-size: 24px;
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
html.dark .brand a {
|
||||
color: #e5e7eb;
|
||||
.badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
padding: 2px 8px;
|
||||
border-radius: 99px;
|
||||
border: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #409eff;
|
||||
.badge.server-badge {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
html.dark .badge.server-badge {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #22d3ee 100%);
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.github-link {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
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;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: var(--text-primary);
|
||||
transition: background 0.2s;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.github-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #606266;
|
||||
transition: color 0.2s;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.github-link:hover .github-icon {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
html.dark .github-icon {
|
||||
color: #a0a3ad;
|
||||
}
|
||||
|
||||
html.dark .github-link:hover .github-icon {
|
||||
color: #e5e7eb;
|
||||
.github-link:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--header-border);
|
||||
}
|
||||
|
||||
.theme-switch {
|
||||
--el-switch-on-color: #2c2c3a;
|
||||
--el-switch-off-color: #f2f2f2;
|
||||
--el-switch-border-color: #dcdfe6;
|
||||
--el-switch-border-color: var(--header-border);
|
||||
}
|
||||
|
||||
html.dark .theme-switch {
|
||||
--el-switch-off-color: #333;
|
||||
}
|
||||
|
||||
.theme-switch .el-switch__core .el-switch__inner .el-icon {
|
||||
color: #909399 !important;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: relative;
|
||||
padding: 0 32px;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
.nav-bar {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
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;
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 0 12px !important;
|
||||
margin: 7px 0;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-menu > .el-menu-item,
|
||||
.nav-menu > .el-sub-menu {
|
||||
margin-right: 4px;
|
||||
.nav-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.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;
|
||||
.nav-link.active {
|
||||
color: var(--active-link);
|
||||
border-bottom-color: var(--active-link);
|
||||
}
|
||||
|
||||
#content {
|
||||
flex: 1;
|
||||
padding: 24px 40px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-top {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
padding: 0 16px;
|
||||
.header-content {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-size: 18px;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,11 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`)
|
||||
throw new HTTPError(
|
||||
response.status,
|
||||
response.statusText,
|
||||
`HTTP ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Handle empty response (e.g. 204 No Content)
|
||||
@@ -31,20 +35,22 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
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',
|
||||
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)
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
put: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||
request<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
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)
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }),
|
||||
delete: <T>(url: string, options?: RequestInit) =>
|
||||
request<T>(url, { ...options, method: 'DELETE' }),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { http } from './http'
|
||||
import type { GetProxyResponse, ProxyStatsInfo, TrafficResponse } from '../types/proxy'
|
||||
import type {
|
||||
GetProxyResponse,
|
||||
ProxyStatsInfo,
|
||||
TrafficResponse,
|
||||
} from '../types/proxy'
|
||||
|
||||
export const getProxiesByType = (type: string) => {
|
||||
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||
@@ -9,6 +13,10 @@ export const getProxy = (type: string, name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyByName = (name: string) => {
|
||||
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||
}
|
||||
|
||||
export const getProxyTraffic = (name: string) => {
|
||||
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
.el-page-header__title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Better form layouts */
|
||||
|
||||
15
web/frps/src/assets/icons/logo.svg
Normal file
15
web/frps/src/assets/icons/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
|
||||
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
|
||||
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
|
||||
<path
|
||||
d="M37 28 V72
|
||||
M37 28 H63
|
||||
M37 50 H55"
|
||||
fill="none"
|
||||
stroke="#FFFFFF"
|
||||
stroke-width="14"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 448 B |
@@ -1,65 +1,48 @@
|
||||
<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 class="client-card" @click="viewDetail">
|
||||
<div class="card-icon-wrapper">
|
||||
<div
|
||||
class="status-dot-large"
|
||||
:class="client.online ? 'online' : 'offline'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<span class="client-main-id">{{ client.displayName }}</span>
|
||||
<span v-if="client.hostname" class="hostname-badge">{{
|
||||
client.hostname
|
||||
}}</span>
|
||||
</div>
|
||||
<el-tag :type="client.statusColor" size="small">
|
||||
|
||||
<div class="card-meta">
|
||||
<div class="meta-group">
|
||||
<span v-if="client.ip" class="meta-item">
|
||||
<span class="meta-label">IP</span>
|
||||
<span class="meta-value">{{ client.ip }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="meta-item activity">
|
||||
<el-icon class="activity-icon"><DataLine /></el-icon>
|
||||
<span class="meta-value">{{
|
||||
client.online ? client.lastConnectedAgo : client.disconnectedAgo
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-action">
|
||||
<div class="status-badge" :class="client.online ? 'online' : 'offline'">
|
||||
{{ client.online ? 'Online' : 'Offline' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
|
||||
<div class="client-info">
|
||||
<div class="info-row">
|
||||
<el-icon class="info-icon"><Monitor /></el-icon>
|
||||
<span class="info-label">Hostname:</span>
|
||||
<span class="info-value">{{ client.hostname || 'N/A' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="client.ip">
|
||||
<el-icon class="info-icon"><Connection /></el-icon>
|
||||
<span class="info-label">IP:</span>
|
||||
<span class="info-value monospace">{{ client.ip }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="client.user">
|
||||
<el-icon class="info-icon"><User /></el-icon>
|
||||
<span class="info-label">User:</span>
|
||||
<span class="info-value">{{ client.user }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<el-icon class="info-icon"><Key /></el-icon>
|
||||
<span class="info-label">Run ID:</span>
|
||||
<span class="info-value monospace">{{ client.runID }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="client.firstConnectedAt">
|
||||
<el-icon class="info-icon"><Clock /></el-icon>
|
||||
<span class="info-label">First Connected:</span>
|
||||
<span class="info-value">{{ client.firstConnectedAgo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="client.online">
|
||||
<el-icon class="info-icon"><Clock /></el-icon>
|
||||
<span class="info-label">Last Connected:</span>
|
||||
<span class="info-value">{{ client.lastConnectedAgo }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row" v-if="!client.online && client.disconnectedAt">
|
||||
<el-icon class="info-icon"><CircleClose /></el-icon>
|
||||
<span class="info-label">Disconnected:</span>
|
||||
<span class="info-value">{{ client.disconnectedAgo }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Monitor, User, Key, Clock, CircleClose, Connection } from '@element-plus/icons-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { DataLine, ArrowRight } from '@element-plus/icons-vue'
|
||||
import type { Client } from '../utils/client'
|
||||
|
||||
interface Props {
|
||||
@@ -67,124 +50,209 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const router = useRouter()
|
||||
|
||||
const statusClass = computed(() => {
|
||||
return `status-${props.client.statusColor}`
|
||||
})
|
||||
const viewDetail = () => {
|
||||
router.push({
|
||||
name: 'ClientDetail',
|
||||
params: { key: props.client.key },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.client-card {
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
border: 1px solid #e4e7ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 24px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.client-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||||
border-color: var(--el-border-color-light);
|
||||
}
|
||||
|
||||
html.dark .client-card {
|
||||
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 {
|
||||
.card-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
.client-card:hover .card-icon-wrapper {
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
.status-dot-large {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: #67c23a;
|
||||
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.7);
|
||||
.status-dot-large.online {
|
||||
background-color: var(--el-color-success);
|
||||
box-shadow: 0 0 0 2px var(--el-color-success-light-8);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: #e6a23c;
|
||||
box-shadow: 0 0 0 0 rgba(230, 162, 60, 0.7);
|
||||
.status-dot-large.offline {
|
||||
background-color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.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 {
|
||||
.card-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.client-main-id {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hostname-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-dark);
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-regular);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
html.dark .info-icon {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #909399;
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 100px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.dark .info-label {
|
||||
color: #9ca3af;
|
||||
.activity .meta-value {
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #606266;
|
||||
flex: 1;
|
||||
.card-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html.dark .info-value {
|
||||
color: #d1d5db;
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.monospace {
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
.status-badge.online {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.arrow-icon {
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.client-card:hover .arrow-icon {
|
||||
color: var(--el-text-color-primary);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
html.dark .card-icon-wrapper {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
html.dark .client-card:hover .card-icon-wrapper {
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
html.dark .status-dot-large.online {
|
||||
box-shadow: 0 0 0 2px rgba(var(--el-color-success-rgb), 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.client-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-icon-wrapper {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card-action {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
244
web/frps/src/components/ProxyCard.vue
Normal file
244
web/frps/src/components/ProxyCard.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<router-link :to="proxyLink" class="proxy-card">
|
||||
<div class="card-main">
|
||||
<div class="card-left">
|
||||
<div class="card-header">
|
||||
<span class="proxy-name">{{ proxy.name }}</span>
|
||||
<span v-if="showType" class="type-tag">{{
|
||||
proxy.type.toUpperCase()
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="proxy.port" class="meta-item">
|
||||
<span class="meta-label">Port:</span>
|
||||
<span class="meta-value">{{ proxy.port }}</span>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">Connections:</span>
|
||||
<span class="meta-value">{{ proxy.conns }}</span>
|
||||
</span>
|
||||
<span class="meta-item" v-if="proxy.clientID">
|
||||
<span class="meta-label">Client:</span>
|
||||
<span class="meta-value">{{
|
||||
proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-right">
|
||||
<div class="traffic-stats">
|
||||
<div class="traffic-row">
|
||||
<el-icon class="traffic-icon out"><Top /></el-icon>
|
||||
<span class="traffic-value">{{
|
||||
formatFileSize(proxy.trafficOut)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="traffic-row">
|
||||
<el-icon class="traffic-icon in"><Bottom /></el-icon>
|
||||
<span class="traffic-value">{{
|
||||
formatFileSize(proxy.trafficIn)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-badge" :class="proxy.status">
|
||||
{{ proxy.status }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Top, Bottom } from '@element-plus/icons-vue'
|
||||
import { formatFileSize } from '../utils/format'
|
||||
import type { BaseProxy } from '../utils/proxy'
|
||||
|
||||
interface Props {
|
||||
proxy: BaseProxy
|
||||
showType?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const route = useRoute()
|
||||
|
||||
const proxyLink = computed(() => {
|
||||
const base = `/proxy/${props.proxy.name}`
|
||||
// If we're on a client detail page, pass client info
|
||||
if (route.name === 'ClientDetail' && route.params.key) {
|
||||
return `${base}?from=client&client=${route.params.key}`
|
||||
}
|
||||
return base
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-card {
|
||||
display: block;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.proxy-card:hover {
|
||||
border-color: var(--el-border-color-light);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
gap: 24px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
/* Left Section */
|
||||
.card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* Right Section */
|
||||
.card-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.traffic-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.traffic-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.traffic-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.traffic-icon.in {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.traffic-icon.out {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.traffic-value {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--el-color-danger-light-9);
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.card-main {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card-right {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.traffic-stats {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<el-form
|
||||
label-position="left"
|
||||
label-width="auto"
|
||||
inline
|
||||
class="proxy-table-expand"
|
||||
>
|
||||
<el-form-item label="Name">
|
||||
<span>{{ row.name }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Type">
|
||||
<span>{{ row.type }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Encryption">
|
||||
<span>{{ row.encryption }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Compression">
|
||||
<span>{{ row.compression }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Last Start">
|
||||
<span>{{ row.lastStartTime }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Last Close">
|
||||
<span>{{ row.lastCloseTime }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<div v-if="proxyType === 'http' || proxyType === 'https'">
|
||||
<el-form-item label="Domains">
|
||||
<span>{{ row.customDomains }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="SubDomain">
|
||||
<span>{{ row.subdomain }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="locations">
|
||||
<span>{{ row.locations }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="HostRewrite">
|
||||
<span>{{ row.hostHeaderRewrite }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-else-if="proxyType === 'tcpmux'">
|
||||
<el-form-item label="Multiplexer">
|
||||
<span>{{ row.multiplexer }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="RouteByHTTPUser">
|
||||
<span>{{ row.routeByHTTPUser }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Domains">
|
||||
<span>{{ row.customDomains }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="SubDomain">
|
||||
<span>{{ row.subdomain }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div v-else>
|
||||
<el-form-item label="Addr">
|
||||
<span>{{ row.addr }}</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div v-if="row.annotations && row.annotations.size > 0">
|
||||
<el-divider />
|
||||
<el-text class="title-text" size="large">Annotations</el-text>
|
||||
<ul>
|
||||
<li v-for="item in annotationsArray()" :key="item.key">
|
||||
<span class="annotation-key">{{ item.key }}</span>
|
||||
<span>{{ item.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
row: any
|
||||
proxyType: string
|
||||
}>()
|
||||
|
||||
// annotationsArray returns an array of key-value pairs from the annotations map.
|
||||
const annotationsArray = (): Array<{ key: string; value: string }> => {
|
||||
const array: Array<{ key: string; value: any }> = []
|
||||
if (props.row.annotations) {
|
||||
props.row.annotations.forEach((value: any, key: string) => {
|
||||
array.push({ key, value })
|
||||
})
|
||||
}
|
||||
return array
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
ul {
|
||||
list-style-type: none;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
justify-content: space-between;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
ul .annotation-key {
|
||||
width: 300px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
color: #99a9bf;
|
||||
}
|
||||
</style>
|
||||
@@ -167,7 +167,7 @@ html.dark .icon-traffic {
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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>
|
||||
@@ -15,15 +15,21 @@
|
||||
|
||||
<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"
|
||||
<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"
|
||||
<el-tooltip
|
||||
:content="`Out: ${formatFileSize(item.out)}`"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
class="bar bar-out"
|
||||
:style="{ height: Math.max(item.outPercent, 1) + '%' }"
|
||||
></div>
|
||||
</el-tooltip>
|
||||
@@ -32,15 +38,11 @@
|
||||
</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 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" />
|
||||
@@ -58,24 +60,26 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const chartData = ref<Array<{
|
||||
date: string
|
||||
in: number
|
||||
out: number
|
||||
inPercent: number
|
||||
outPercent: number
|
||||
}>>([])
|
||||
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)
|
||||
@@ -84,7 +88,7 @@ const processData = (trafficIn: number[], trafficOut: number[]) => {
|
||||
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)
|
||||
@@ -179,9 +183,16 @@ 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 */
|
||||
.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;
|
||||
@@ -255,6 +266,10 @@ html.dark .legend-item {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot.in { background-color: #5470c6; }
|
||||
.dot.out { background-color: #91cc75; }
|
||||
</style>
|
||||
.dot.in {
|
||||
background-color: #5470c6;
|
||||
}
|
||||
.dot.out {
|
||||
background-color: #91cc75;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import ServerOverview from '../views/ServerOverview.vue'
|
||||
import Clients from '../views/Clients.vue'
|
||||
import ClientDetail from '../views/ClientDetail.vue'
|
||||
import Proxies from '../views/Proxies.vue'
|
||||
import ProxyDetail from '../views/ProxyDetail.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
@@ -16,11 +21,21 @@ const router = createRouter({
|
||||
name: 'Clients',
|
||||
component: Clients,
|
||||
},
|
||||
{
|
||||
path: '/clients/:key',
|
||||
name: 'ClientDetail',
|
||||
component: ClientDetail,
|
||||
},
|
||||
{
|
||||
path: '/proxies/:type?',
|
||||
name: 'Proxies',
|
||||
component: Proxies,
|
||||
},
|
||||
{
|
||||
path: '/proxy/:name',
|
||||
name: 'ProxyDetail',
|
||||
component: ProxyDetail,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface ProxyStatsInfo {
|
||||
name: string
|
||||
conf: any
|
||||
user: string
|
||||
clientID: string
|
||||
clientVersion: string
|
||||
todayTrafficIn: number
|
||||
todayTrafficOut: number
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface ServerInfo {
|
||||
heartbeatTimeout: number
|
||||
allowPortsStr: string
|
||||
tlsForce: boolean
|
||||
|
||||
|
||||
// Stats
|
||||
totalTrafficIn: number
|
||||
totalTrafficOut: number
|
||||
|
||||
@@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ class BaseProxy {
|
||||
lastStartTime: string
|
||||
lastCloseTime: string
|
||||
status: string
|
||||
user: string
|
||||
clientID: string
|
||||
clientVersion: string
|
||||
addr: string
|
||||
port: number
|
||||
@@ -19,6 +21,10 @@ class BaseProxy {
|
||||
locations: string
|
||||
subdomain: string
|
||||
|
||||
// TCPMux specific
|
||||
multiplexer: string
|
||||
routeByHTTPUser: string
|
||||
|
||||
constructor(proxyStats: any) {
|
||||
this.name = proxyStats.name
|
||||
this.type = ''
|
||||
@@ -41,6 +47,8 @@ class BaseProxy {
|
||||
this.lastStartTime = proxyStats.lastStartTime
|
||||
this.lastCloseTime = proxyStats.lastCloseTime
|
||||
this.status = proxyStats.status
|
||||
this.user = proxyStats.user || ''
|
||||
this.clientID = proxyStats.clientID || ''
|
||||
this.clientVersion = proxyStats.clientVersion
|
||||
|
||||
this.addr = ''
|
||||
@@ -49,6 +57,8 @@ class BaseProxy {
|
||||
this.hostHeaderRewrite = ''
|
||||
this.locations = ''
|
||||
this.subdomain = ''
|
||||
this.multiplexer = ''
|
||||
this.routeByHTTPUser = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,20 +121,15 @@ class HTTPSProxy extends BaseProxy {
|
||||
}
|
||||
|
||||
class TCPMuxProxy extends BaseProxy {
|
||||
multiplexer: string
|
||||
routeByHTTPUser: string
|
||||
|
||||
constructor(proxyStats: any, port: number, subdomainHost: string) {
|
||||
super(proxyStats)
|
||||
this.type = 'tcpmux'
|
||||
this.port = port
|
||||
this.multiplexer = ''
|
||||
this.routeByHTTPUser = ''
|
||||
|
||||
if (proxyStats.conf) {
|
||||
this.customDomains = proxyStats.conf.customDomains || this.customDomains
|
||||
this.multiplexer = proxyStats.conf.multiplexer
|
||||
this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser
|
||||
this.multiplexer = proxyStats.conf.multiplexer || ''
|
||||
this.routeByHTTPUser = proxyStats.conf.routeByHTTPUser || ''
|
||||
if (proxyStats.conf.subdomain) {
|
||||
this.subdomain = `${proxyStats.conf.subdomain}.${subdomainHost}`
|
||||
}
|
||||
|
||||
522
web/frps/src/views/ClientDetail.vue
Normal file
522
web/frps/src/views/ClientDetail.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="client-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a class="breadcrumb-link" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</a>
|
||||
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<span class="breadcrumb-current">{{
|
||||
client?.displayName || route.params.key
|
||||
}}</span>
|
||||
</nav>
|
||||
|
||||
<div v-loading="loading" class="detail-content">
|
||||
<template v-if="client">
|
||||
<!-- Header Card -->
|
||||
<div class="header-card">
|
||||
<div class="header-main">
|
||||
<div class="header-left">
|
||||
<div class="client-avatar">
|
||||
{{ client.displayName.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="client-info">
|
||||
<h1 class="client-name">{{ client.displayName }}</h1>
|
||||
<div class="client-meta">
|
||||
<span v-if="client.ip" class="meta-item">{{
|
||||
client.ip
|
||||
}}</span>
|
||||
<span v-if="client.hostname" class="meta-item">{{
|
||||
client.hostname
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="client.online ? 'online' : 'offline'"
|
||||
>
|
||||
{{ client.online ? 'Online' : 'Offline' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Section -->
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<span class="info-label">Connections</span>
|
||||
<span class="info-value">{{ totalConnections }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Run ID</span>
|
||||
<span class="info-value">{{ client.runID }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">First Connected</span>
|
||||
<span class="info-value">{{ client.firstConnectedAgo }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">{{
|
||||
client.online ? 'Connected' : 'Disconnected'
|
||||
}}</span>
|
||||
<span class="info-value">{{
|
||||
client.online ? client.lastConnectedAgo : client.disconnectedAgo
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Proxies Card -->
|
||||
<div class="proxies-card">
|
||||
<div class="proxies-header">
|
||||
<div class="proxies-title">
|
||||
<h2>Proxies</h2>
|
||||
<span class="proxies-count">{{ filteredProxies.length }}</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="proxySearch"
|
||||
placeholder="Search proxies..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="proxy-search"
|
||||
/>
|
||||
</div>
|
||||
<div class="proxies-body">
|
||||
<div v-if="proxiesLoading" class="loading-state">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
<div v-else-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
show-type
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="clientProxies.length > 0" class="empty-state">
|
||||
<p>No proxies match "{{ proxySearch }}"</p>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>No proxies found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading" class="not-found">
|
||||
<h2>Client not found</h2>
|
||||
<p>The client doesn't exist or has been removed.</p>
|
||||
<router-link to="/clients">
|
||||
<el-button type="primary">Back to Clients</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft, Loading, Search } from '@element-plus/icons-vue'
|
||||
import { Client } from '../utils/client'
|
||||
import { getClient } from '../api/client'
|
||||
import { getProxiesByType } from '../api/proxy'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
UDPProxy,
|
||||
HTTPProxy,
|
||||
HTTPSProxy,
|
||||
TCPMuxProxy,
|
||||
STCPProxy,
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import ProxyCard from '../components/ProxyCard.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const client = ref<Client | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/clients')
|
||||
}
|
||||
}
|
||||
const proxiesLoading = ref(false)
|
||||
const allProxies = ref<BaseProxy[]>([])
|
||||
const proxySearch = ref('')
|
||||
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const clientProxies = computed(() => {
|
||||
if (!client.value) return []
|
||||
return allProxies.value.filter(
|
||||
(p) =>
|
||||
p.clientID === client.value!.clientID && p.user === client.value!.user,
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProxies = computed(() => {
|
||||
if (!proxySearch.value) return clientProxies.value
|
||||
const search = proxySearch.value.toLowerCase()
|
||||
return clientProxies.value.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(search) ||
|
||||
p.type.toLowerCase().includes(search),
|
||||
)
|
||||
})
|
||||
|
||||
const totalConnections = computed(() => {
|
||||
return clientProxies.value.reduce((sum, p) => sum + p.conns, 0)
|
||||
})
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
const fetchClient = async () => {
|
||||
const key = route.params.key as string
|
||||
if (!key) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await getClient(key)
|
||||
client.value = new Client(data)
|
||||
} catch (error: any) {
|
||||
ElMessage.error('Failed to fetch client: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchProxies = async () => {
|
||||
proxiesLoading.value = true
|
||||
const proxyTypes = ['tcp', 'udp', 'http', 'https', 'tcpmux', 'stcp', 'sudp']
|
||||
const proxies: BaseProxy[] = []
|
||||
try {
|
||||
const info = await fetchServerInfo()
|
||||
for (const type of proxyTypes) {
|
||||
try {
|
||||
const json = await getProxiesByType(type)
|
||||
if (!json.proxies) continue
|
||||
if (type === 'tcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new TCPProxy(p)))
|
||||
} else if (type === 'udp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new UDPProxy(p)))
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPProxy(p, info.vhostHTTPPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new HTTPSProxy(p, info.vhostHTTPSPort, info.subdomainHost),
|
||||
),
|
||||
)
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxies.push(
|
||||
...json.proxies.map(
|
||||
(p: any) =>
|
||||
new TCPMuxProxy(
|
||||
p,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
),
|
||||
),
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new STCPProxy(p)))
|
||||
} else if (type === 'sudp') {
|
||||
proxies.push(...json.proxies.map((p: any) => new SUDPProxy(p)))
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allProxies.value = proxies
|
||||
} catch {
|
||||
// Ignore
|
||||
} finally {
|
||||
proxiesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchClient()
|
||||
fetchProxies()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.client-detail-page {
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Card Base */
|
||||
.header-card,
|
||||
.proxies-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Header Card */
|
||||
.header-main {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.client-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 4px 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.client-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
html.dark .status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
/* Info Section */
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 32px;
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-label::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Proxies Card */
|
||||
.proxies-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.proxies-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proxies-title h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.proxies-count {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
background: var(--hover-bg);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.proxy-search {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.proxy-search :deep(.el-input__wrapper) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.proxies-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.proxies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Not Found */
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.not-found h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.header-main {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,48 @@
|
||||
<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 class="page-header">
|
||||
<div class="header-top">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Clients</h1>
|
||||
<p class="page-subtitle">Manage connected clients and their status</p>
|
||||
</div>
|
||||
<div class="status-tabs">
|
||||
<button
|
||||
v-for="tab in statusTabs"
|
||||
:key="tab.value"
|
||||
class="status-tab"
|
||||
:class="{ active: statusFilter === tab.value }"
|
||||
@click="statusFilter = tab.value"
|
||||
>
|
||||
<span class="status-dot" :class="tab.value"></span>
|
||||
<span class="tab-label">{{ tab.label }}</span>
|
||||
<span class="tab-count">{{ tab.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-section">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search clients..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
</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 v-loading="loading" class="clients-content">
|
||||
<div v-if="filteredClients.length > 0" class="clients-list">
|
||||
<ClientCard
|
||||
v-for="client in filteredClients"
|
||||
:key="client.key"
|
||||
:client="client"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<el-empty description="No clients found" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -55,6 +69,12 @@ const stats = computed(() => {
|
||||
return { total, online, offline }
|
||||
})
|
||||
|
||||
const statusTabs = computed(() => [
|
||||
{ value: 'all' as const, label: 'All', count: stats.value.total },
|
||||
{ value: 'online' as const, label: 'Online', count: stats.value.online },
|
||||
{ value: 'offline' as const, label: 'Offline', count: stats.value.offline },
|
||||
])
|
||||
|
||||
const filteredClients = computed(() => {
|
||||
let result = clients.value
|
||||
|
||||
@@ -87,7 +107,6 @@ const fetchData = async () => {
|
||||
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,
|
||||
@@ -99,7 +118,6 @@ const fetchData = async () => {
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
// Auto refresh every 5 seconds
|
||||
refreshTimer = window.setInterval(() => {
|
||||
fetchData()
|
||||
}, 5000)
|
||||
@@ -124,46 +142,161 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.clients-page {
|
||||
padding: 0 20px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
.page-header {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-filter {
|
||||
flex-shrink: 0;
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.clients-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
|
||||
gap: 20px;
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 20px;
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.status-tab:hover {
|
||||
border-color: var(--el-border-color-darker);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.status-tab.active {
|
||||
background: var(--el-fill-color-dark);
|
||||
border-color: var(--el-text-color-primary);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background-color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.status-dot.all {
|
||||
background-color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.tab-count {
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.search-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
transition: all 0.2s;
|
||||
height: 48px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: var(--el-border-color-darker);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px var(--el-color-primary);
|
||||
}
|
||||
|
||||
.clients-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.clients-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.clients-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
html.dark .status-tab {
|
||||
background: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
html.dark .status-tab.active {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-top {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
max-width: none;
|
||||
.status-tabs {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-tab {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,34 +1,26 @@
|
||||
<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="page-header">
|
||||
<div class="header-top">
|
||||
<div class="title-section">
|
||||
<h1 class="page-title">Proxies</h1>
|
||||
<p class="page-subtitle">View and manage all proxy configurations</p>
|
||||
</div>
|
||||
|
||||
<div class="actions-section">
|
||||
<el-button :icon="Refresh" class="action-btn" @click="fetchData"
|
||||
>Refresh</el-button
|
||||
>
|
||||
|
||||
<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?"
|
||||
title="Clear all offline proxies?"
|
||||
width="220"
|
||||
confirm-button-text="Clear"
|
||||
cancel-button-text="Cancel"
|
||||
@confirm="clearOfflineProxies"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="danger" plain :icon="Delete"
|
||||
<el-button :icon="Delete" class="action-btn" type="danger" plain
|
||||
>Clear Offline</el-button
|
||||
>
|
||||
</template>
|
||||
@@ -36,118 +28,74 @@
|
||||
</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>
|
||||
<div class="filter-section">
|
||||
<div class="search-row">
|
||||
<el-input
|
||||
v-model="searchText"
|
||||
placeholder="Search proxies..."
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
class="main-search"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
destroy-on-close
|
||||
:title="`Traffic Statistics - ${dialogVisibleName}`"
|
||||
width="700px"
|
||||
align-center
|
||||
class="traffic-dialog"
|
||||
>
|
||||
<Traffic :proxyName="dialogVisibleName" />
|
||||
</el-dialog>
|
||||
<el-select
|
||||
:model-value="selectedClientKey"
|
||||
placeholder="All Clients"
|
||||
clearable
|
||||
filterable
|
||||
class="client-select"
|
||||
@change="onClientFilterChange"
|
||||
>
|
||||
<el-option label="All Clients" value="" />
|
||||
<el-option
|
||||
v-if="clientIDFilter && !selectedClientInList"
|
||||
:label="`${userFilter ? userFilter + '.' : ''}${clientIDFilter} (not found)`"
|
||||
:value="selectedClientKey"
|
||||
style="color: var(--el-color-warning); font-style: italic"
|
||||
/>
|
||||
<el-option
|
||||
v-for="client in clientOptions"
|
||||
:key="client.key"
|
||||
:label="client.label"
|
||||
:value="client.key"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
v-for="t in proxyTypes"
|
||||
:key="t.value"
|
||||
class="type-tab"
|
||||
:class="{ active: activeType === t.value }"
|
||||
@click="activeType = t.value"
|
||||
>
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="proxies-content">
|
||||
<div v-if="filteredProxies.length > 0" class="proxies-list">
|
||||
<ProxyCard
|
||||
v-for="proxy in filteredProxies"
|
||||
:key="proxy.name"
|
||||
:proxy="proxy"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty-state">
|
||||
<el-empty description="No proxies found" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 { Search, Refresh, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
@@ -158,10 +106,14 @@ import {
|
||||
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 ProxyCard from '../components/ProxyCard.vue'
|
||||
import {
|
||||
getProxiesByType,
|
||||
clearOfflineProxies as apiClearOfflineProxies,
|
||||
} from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import { getClients } from '../api/client'
|
||||
import { Client } from '../utils/client'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -178,19 +130,85 @@ const proxyTypes = [
|
||||
|
||||
const activeType = ref((route.params.type as string) || 'tcp')
|
||||
const proxies = ref<BaseProxy[]>([])
|
||||
const clients = ref<Client[]>([])
|
||||
const loading = ref(false)
|
||||
const searchText = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const dialogVisibleName = ref('')
|
||||
const clientIDFilter = ref((route.query.clientID as string) || '')
|
||||
const userFilter = ref((route.query.user as string) || '')
|
||||
|
||||
const clientOptions = computed(() => {
|
||||
return clients.value
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
clientID: c.clientID,
|
||||
user: c.user,
|
||||
label: c.user ? `${c.user}.${c.clientID}` : c.clientID,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
})
|
||||
|
||||
// Compute selected client key for el-select v-model
|
||||
const selectedClientKey = computed(() => {
|
||||
if (!clientIDFilter.value) return ''
|
||||
const client = clientOptions.value.find(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
// Return a synthetic key even if not found, so the select shows the filter is active
|
||||
return client?.key || `${userFilter.value}:${clientIDFilter.value}`
|
||||
})
|
||||
|
||||
// Check if the filtered client exists in the client list
|
||||
const selectedClientInList = computed(() => {
|
||||
if (!clientIDFilter.value) return true
|
||||
return clientOptions.value.some(
|
||||
(c) => c.clientID === clientIDFilter.value && c.user === userFilter.value,
|
||||
)
|
||||
})
|
||||
|
||||
const filteredProxies = computed(() => {
|
||||
if (!searchText.value) {
|
||||
return proxies.value
|
||||
let result = proxies.value
|
||||
|
||||
// Filter by clientID and user if specified
|
||||
if (clientIDFilter.value) {
|
||||
result = result.filter(
|
||||
(p) => p.clientID === clientIDFilter.value && p.user === userFilter.value,
|
||||
)
|
||||
}
|
||||
const search = searchText.value.toLowerCase()
|
||||
return proxies.value.filter((p) => p.name.toLowerCase().includes(search))
|
||||
|
||||
// Filter by search text
|
||||
if (searchText.value) {
|
||||
const search = searchText.value.toLowerCase()
|
||||
result = result.filter((p) => p.name.toLowerCase().includes(search))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const onClientFilterChange = (key: string) => {
|
||||
if (key) {
|
||||
const client = clientOptions.value.find((c) => c.key === key)
|
||||
if (client) {
|
||||
router.replace({
|
||||
query: { ...route.query, clientID: client.clientID, user: client.user },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const query = { ...route.query }
|
||||
delete query.clientID
|
||||
delete query.user
|
||||
router.replace({ query })
|
||||
}
|
||||
}
|
||||
|
||||
const fetchClients = async () => {
|
||||
try {
|
||||
const json = await getClients()
|
||||
clients.value = json.map((data) => new Client(data))
|
||||
} catch {
|
||||
// Ignore errors when fetching clients
|
||||
}
|
||||
}
|
||||
|
||||
// Server info cache
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
@@ -247,7 +265,6 @@ const fetchData = async () => {
|
||||
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,
|
||||
@@ -258,11 +275,6 @@ const fetchData = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const showTraffic = (name: string) => {
|
||||
dialogVisibleName.value = name
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const clearOfflineProxies = async () => {
|
||||
try {
|
||||
await apiClearOfflineProxies()
|
||||
@@ -281,95 +293,177 @@ const clearOfflineProxies = async () => {
|
||||
|
||||
// Watch for type changes
|
||||
watch(activeType, (newType) => {
|
||||
router.replace({ params: { type: newType } })
|
||||
// Update route but preserve query params
|
||||
router.replace({ params: { type: newType }, query: route.query })
|
||||
fetchData()
|
||||
})
|
||||
|
||||
// Watch for route query changes (client filter)
|
||||
watch(
|
||||
() => [route.query.clientID, route.query.user],
|
||||
([newClientID, newUser]) => {
|
||||
clientIDFilter.value = (newClientID as string) || ''
|
||||
userFilter.value = (newUser as string) || ''
|
||||
},
|
||||
)
|
||||
|
||||
// Initial fetch
|
||||
fetchData()
|
||||
fetchClients()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxies-page {
|
||||
padding: 24px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-card {
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.toolbar-header {
|
||||
.header-top {
|
||||
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;
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.proxy-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0;
|
||||
.title-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proxy-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
height: 0;
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions-section {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
height: 36px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
.main-search {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.main-search,
|
||||
.client-select {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper),
|
||||
.client-select :deep(.el-input__wrapper) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 0 16px;
|
||||
height: 100%;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.main-search :deep(.el-input__wrapper) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.client-select {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.traffic-cell {
|
||||
.client-select :deep(.el-select__wrapper) {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 0 12px;
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-tab:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.type-tab.active {
|
||||
background: var(--el-fill-color-darker);
|
||||
color: var(--el-text-color-primary);
|
||||
border-color: var(--el-fill-color-darker);
|
||||
}
|
||||
|
||||
.proxies-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.proxies-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.traffic-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.empty-state {
|
||||
padding: 60px 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.search-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
.client-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
985
web/frps/src/views/ProxyDetail.vue
Normal file
985
web/frps/src/views/ProxyDetail.vue
Normal file
@@ -0,0 +1,985 @@
|
||||
<template>
|
||||
<div class="proxy-detail-page">
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb">
|
||||
<a class="breadcrumb-link" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</a>
|
||||
<template v-if="fromClient">
|
||||
<router-link to="/clients" class="breadcrumb-item">Clients</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<router-link :to="`/clients/${fromClient}`" class="breadcrumb-item">{{
|
||||
fromClient
|
||||
}}</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/proxies" class="breadcrumb-item">Proxies</router-link>
|
||||
<span class="breadcrumb-separator">/</span>
|
||||
<router-link
|
||||
v-if="proxy?.clientID"
|
||||
:to="clientLink"
|
||||
class="breadcrumb-item"
|
||||
>
|
||||
{{ proxy.user ? `${proxy.user}.${proxy.clientID}` : proxy.clientID }}
|
||||
</router-link>
|
||||
<span v-if="proxy?.clientID" class="breadcrumb-separator">/</span>
|
||||
</template>
|
||||
<span class="breadcrumb-current">{{ proxyName }}</span>
|
||||
</nav>
|
||||
|
||||
<div v-loading="loading" class="detail-content">
|
||||
<template v-if="proxy">
|
||||
<!-- Header Section -->
|
||||
<div class="header-section">
|
||||
<div class="header-main">
|
||||
<div
|
||||
class="proxy-icon"
|
||||
:style="{ background: proxyIconConfig.gradient }"
|
||||
>
|
||||
<el-icon><component :is="proxyIconConfig.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<div class="header-title-row">
|
||||
<h1 class="proxy-name">{{ proxy.name }}</h1>
|
||||
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
|
||||
<span class="status-badge" :class="proxy.status">
|
||||
{{ proxy.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<router-link
|
||||
v-if="proxy.clientID"
|
||||
:to="clientLink"
|
||||
class="client-link"
|
||||
>
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span
|
||||
>Client:
|
||||
{{
|
||||
proxy.user
|
||||
? `${proxy.user}.${proxy.clientID}`
|
||||
: proxy.clientID
|
||||
}}</span
|
||||
>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div v-if="proxy.port" class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Port</span>
|
||||
<div class="stat-icon port">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ proxy.port }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Connections</span>
|
||||
<div class="stat-icon connections">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">{{ proxy.conns }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Traffic In</span>
|
||||
<div class="stat-icon traffic-in">
|
||||
<el-icon><Bottom /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="value-number">{{
|
||||
formatTrafficValue(proxy.trafficIn)
|
||||
}}</span>
|
||||
<span class="value-unit">{{
|
||||
formatTrafficUnit(proxy.trafficIn)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-header">
|
||||
<span class="stat-label">Traffic Out</span>
|
||||
<div class="stat-icon traffic-out">
|
||||
<el-icon><Top /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-value">
|
||||
<span class="value-number">{{
|
||||
formatTrafficValue(proxy.trafficOut)
|
||||
}}</span>
|
||||
<span class="value-unit">{{
|
||||
formatTrafficUnit(proxy.trafficOut)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Timeline -->
|
||||
<div class="timeline-card">
|
||||
<div class="timeline-header">
|
||||
<el-icon><DataLine /></el-icon>
|
||||
<h2>Status Timeline</h2>
|
||||
</div>
|
||||
<div class="timeline-body">
|
||||
<div class="timeline-grid">
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Last Start Time</span>
|
||||
<span class="timeline-value">{{
|
||||
proxy.lastStartTime || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<span class="timeline-label">Last Close Time</span>
|
||||
<span class="timeline-value">{{
|
||||
proxy.lastCloseTime || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<div class="config-section">
|
||||
<div class="config-section-header">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<h2>Configuration</h2>
|
||||
</div>
|
||||
|
||||
<!-- Config Cards Grid -->
|
||||
<div class="config-grid">
|
||||
<div class="config-item-card">
|
||||
<div class="config-item-icon encryption">
|
||||
<el-icon><Lock /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Encryption</span>
|
||||
<span class="config-item-value">{{
|
||||
proxy.encryption ? 'Enabled' : 'Disabled'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-item-card">
|
||||
<div class="config-item-icon compression">
|
||||
<el-icon><Lightning /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Compression</span>
|
||||
<span class="config-item-value">{{
|
||||
proxy.compression ? 'Enabled' : 'Disabled'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.customDomains" class="config-item-card">
|
||||
<div class="config-item-icon domains">
|
||||
<el-icon><Link /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Custom Domains</span>
|
||||
<span class="config-item-value">{{ proxy.customDomains }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.subdomain" class="config-item-card">
|
||||
<div class="config-item-icon subdomain">
|
||||
<el-icon><Link /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Subdomain</span>
|
||||
<span class="config-item-value">{{ proxy.subdomain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.locations" class="config-item-card">
|
||||
<div class="config-item-icon locations">
|
||||
<el-icon><Location /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Locations</span>
|
||||
<span class="config-item-value">{{ proxy.locations }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.hostHeaderRewrite" class="config-item-card">
|
||||
<div class="config-item-icon host">
|
||||
<el-icon><Tickets /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Host Rewrite</span>
|
||||
<span class="config-item-value">{{
|
||||
proxy.hostHeaderRewrite
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.multiplexer" class="config-item-card">
|
||||
<div class="config-item-icon multiplexer">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Multiplexer</span>
|
||||
<span class="config-item-value">{{ proxy.multiplexer }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="proxy.routeByHTTPUser" class="config-item-card">
|
||||
<div class="config-item-icon route">
|
||||
<el-icon><Connection /></el-icon>
|
||||
</div>
|
||||
<div class="config-item-content">
|
||||
<span class="config-item-label">Route By HTTP User</span>
|
||||
<span class="config-item-value">{{
|
||||
proxy.routeByHTTPUser
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Annotations -->
|
||||
<template v-if="proxy.annotations && proxy.annotations.size > 0">
|
||||
<div class="annotations-section">
|
||||
<div
|
||||
v-for="[key, value] in proxy.annotations"
|
||||
:key="key"
|
||||
class="annotation-tag"
|
||||
>
|
||||
{{ key }}: {{ value }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Card -->
|
||||
<div class="traffic-card">
|
||||
<div class="traffic-header">
|
||||
<h2>Traffic Statistics</h2>
|
||||
</div>
|
||||
<div class="traffic-body">
|
||||
<Traffic :proxy-name="proxyName" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading" class="not-found">
|
||||
<h2>Proxy not found</h2>
|
||||
<p>The proxy doesn't exist or has been removed.</p>
|
||||
<router-link to="/proxies">
|
||||
<el-button type="primary">Back to Proxies</el-button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Monitor,
|
||||
Connection,
|
||||
DataLine,
|
||||
Bottom,
|
||||
Top,
|
||||
Link,
|
||||
Lock,
|
||||
Promotion,
|
||||
Grid,
|
||||
Setting,
|
||||
Cpu,
|
||||
Lightning,
|
||||
Tickets,
|
||||
Location,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getProxyByName } from '../api/proxy'
|
||||
import { getServerInfo } from '../api/server'
|
||||
import {
|
||||
BaseProxy,
|
||||
TCPProxy,
|
||||
UDPProxy,
|
||||
HTTPProxy,
|
||||
HTTPSProxy,
|
||||
TCPMuxProxy,
|
||||
STCPProxy,
|
||||
SUDPProxy,
|
||||
} from '../utils/proxy'
|
||||
import Traffic from '../components/Traffic.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const proxyName = computed(() => route.params.name as string)
|
||||
const fromClient = computed(() => {
|
||||
if (route.query.from === 'client' && route.query.client) {
|
||||
return route.query.client as string
|
||||
}
|
||||
return null
|
||||
})
|
||||
const proxy = ref<BaseProxy | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/proxies')
|
||||
}
|
||||
}
|
||||
|
||||
let serverInfo: {
|
||||
vhostHTTPPort: number
|
||||
vhostHTTPSPort: number
|
||||
tcpmuxHTTPConnectPort: number
|
||||
subdomainHost: string
|
||||
} | null = null
|
||||
|
||||
const clientLink = computed(() => {
|
||||
if (!proxy.value) return ''
|
||||
const key = proxy.value.user
|
||||
? `${proxy.value.user}.${proxy.value.clientID}`
|
||||
: proxy.value.clientID
|
||||
return `/clients/${key}`
|
||||
})
|
||||
|
||||
const proxyIconConfig = computed(() => {
|
||||
const type = proxy.value?.type?.toLowerCase() || ''
|
||||
const configs: Record<string, { icon: any; gradient: string }> = {
|
||||
tcp: {
|
||||
icon: Connection,
|
||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
},
|
||||
udp: {
|
||||
icon: Promotion,
|
||||
gradient: 'linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%)',
|
||||
},
|
||||
http: {
|
||||
icon: Link,
|
||||
gradient: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||
},
|
||||
https: {
|
||||
icon: Lock,
|
||||
gradient: 'linear-gradient(135deg, #14b8a6 0%, #0d9488 100%)',
|
||||
},
|
||||
stcp: {
|
||||
icon: Lock,
|
||||
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
|
||||
},
|
||||
sudp: {
|
||||
icon: Lock,
|
||||
gradient: 'linear-gradient(135deg, #f97316 0%, #ea580c 100%)',
|
||||
},
|
||||
tcpmux: {
|
||||
icon: Grid,
|
||||
gradient: 'linear-gradient(135deg, #06b6d4 0%, #0891b2 100%)',
|
||||
},
|
||||
xtcp: {
|
||||
icon: Connection,
|
||||
gradient: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
|
||||
},
|
||||
}
|
||||
return (
|
||||
configs[type] || {
|
||||
icon: Connection,
|
||||
gradient: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)',
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const formatTrafficValue = (bytes: number): string => {
|
||||
if (bytes === 0) return '0'
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
const value = bytes / Math.pow(k, i)
|
||||
return value < 10 ? value.toFixed(1) : Math.round(value).toString()
|
||||
}
|
||||
|
||||
const formatTrafficUnit = (bytes: number): string => {
|
||||
if (bytes === 0) return 'B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const k = 1024
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return units[i]
|
||||
}
|
||||
|
||||
const fetchServerInfo = async () => {
|
||||
if (serverInfo) return serverInfo
|
||||
const res = await getServerInfo()
|
||||
serverInfo = res
|
||||
return serverInfo
|
||||
}
|
||||
|
||||
const fetchProxy = async () => {
|
||||
const name = proxyName.value
|
||||
if (!name) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getProxyByName(name)
|
||||
const info = await fetchServerInfo()
|
||||
const type = data.conf?.type || ''
|
||||
|
||||
if (type === 'tcp') {
|
||||
proxy.value = new TCPProxy(data)
|
||||
} else if (type === 'udp') {
|
||||
proxy.value = new UDPProxy(data)
|
||||
} else if (type === 'http' && info?.vhostHTTPPort) {
|
||||
proxy.value = new HTTPProxy(data, info.vhostHTTPPort, info.subdomainHost)
|
||||
} else if (type === 'https' && info?.vhostHTTPSPort) {
|
||||
proxy.value = new HTTPSProxy(
|
||||
data,
|
||||
info.vhostHTTPSPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'tcpmux' && info?.tcpmuxHTTPConnectPort) {
|
||||
proxy.value = new TCPMuxProxy(
|
||||
data,
|
||||
info.tcpmuxHTTPConnectPort,
|
||||
info.subdomainHost,
|
||||
)
|
||||
} else if (type === 'stcp') {
|
||||
proxy.value = new STCPProxy(data)
|
||||
} else if (type === 'sudp') {
|
||||
proxy.value = new SUDPProxy(data)
|
||||
} else {
|
||||
proxy.value = new BaseProxy(data)
|
||||
proxy.value.type = type
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error('Failed to fetch proxy: ' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProxy()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.proxy-detail-page {
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-item:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.header-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header-main {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.proxy-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 26px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.proxy-name {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.type-tag {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--el-fill-color-dark);
|
||||
color: var(--el-text-color-secondary);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.status-badge.offline {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
html.dark .status-badge.online {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.client-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.client-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.stat-icon.port {
|
||||
background: rgba(139, 92, 246, 0.1);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.stat-icon.connections {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.stat-icon.traffic-in {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-icon.traffic-out {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
html.dark .stat-icon.port {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
html.dark .stat-icon.connections {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
}
|
||||
|
||||
html.dark .stat-icon.traffic-in {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
html.dark .stat-icon.traffic-out {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.value-number {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-value:not(:has(.value-number)) {
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.value-unit {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Timeline Card */
|
||||
.timeline-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 16px 20px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-header h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.timeline-body {
|
||||
padding: 20px;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 24px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timeline-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline-value {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Card Base */
|
||||
.traffic-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Config Section */
|
||||
.config-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-section-header h2 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.config-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-item-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
padding: 20px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--header-border);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.config-item-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.config-item-icon.encryption {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.config-item-icon.compression {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.config-item-icon.domains {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.config-item-icon.subdomain {
|
||||
background: rgba(168, 85, 247, 0.1);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.config-item-icon.locations {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.config-item-icon.host {
|
||||
background: rgba(249, 115, 22, 0.1);
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.config-item-icon.multiplexer {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.config-item-icon.route {
|
||||
background: rgba(236, 72, 153, 0.1);
|
||||
color: #ec4899;
|
||||
}
|
||||
|
||||
html.dark .config-item-icon.encryption,
|
||||
html.dark .config-item-icon.compression {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
}
|
||||
|
||||
html.dark .config-item-icon.domains,
|
||||
html.dark .config-item-icon.subdomain {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
}
|
||||
|
||||
html.dark .config-item-icon.locations,
|
||||
html.dark .config-item-icon.multiplexer {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
html.dark .config-item-icon.host {
|
||||
background: rgba(249, 115, 22, 0.15);
|
||||
}
|
||||
|
||||
html.dark .config-item-icon.route {
|
||||
background: rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
|
||||
.config-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-item-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-item-value {
|
||||
font-size: 15px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.annotations-section {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.annotation-tag {
|
||||
display: inline-flex;
|
||||
padding: 6px 12px;
|
||||
background: var(--el-fill-color);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.traffic-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--header-border);
|
||||
}
|
||||
|
||||
.traffic-header h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Traffic Card */
|
||||
.traffic-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Not Found */
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.not-found h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.config-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.header-main {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.timeline-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -53,7 +53,9 @@
|
||||
</div>
|
||||
<div class="traffic-info">
|
||||
<div class="label">Inbound</div>
|
||||
<div class="value">{{ formatFileSize(data.totalTrafficIn) }}</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.totalTrafficIn) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="traffic-divider"></div>
|
||||
@@ -63,7 +65,9 @@
|
||||
</div>
|
||||
<div class="traffic-info">
|
||||
<div class="label">Outbound</div>
|
||||
<div class="value">{{ formatFileSize(data.totalTrafficOut) }}</div>
|
||||
<div class="value">
|
||||
{{ formatFileSize(data.totalTrafficOut) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,9 +82,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="proxy-types-grid">
|
||||
<div
|
||||
v-for="(count, type) in data.proxyTypeCounts"
|
||||
:key="type"
|
||||
<div
|
||||
v-for="(count, type) in data.proxyTypeCounts"
|
||||
:key="type"
|
||||
class="proxy-type-item"
|
||||
v-show="count > 0"
|
||||
>
|
||||
@@ -187,7 +191,7 @@ const data = ref({
|
||||
})
|
||||
|
||||
const hasActiveProxies = computed(() => {
|
||||
return Object.values(data.value.proxyTypeCounts).some(c => c > 0)
|
||||
return Object.values(data.value.proxyTypeCounts).some((c) => c > 0)
|
||||
})
|
||||
|
||||
const formatTrafficTotal = () => {
|
||||
@@ -223,7 +227,7 @@ const fetchData = async () => {
|
||||
data.value.proxyCounts = 0
|
||||
if (json.proxyTypeCount != null) {
|
||||
Object.values(json.proxyTypeCount).forEach((count: any) => {
|
||||
data.value.proxyCounts += (count || 0)
|
||||
data.value.proxyCounts += count || 0
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -283,7 +287,7 @@ html.dark .config-card {
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@@ -337,7 +341,7 @@ html.dark .card-title {
|
||||
|
||||
.traffic-info .value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@@ -386,7 +390,7 @@ html.dark .proxy-type-item {
|
||||
|
||||
.proxy-type-count {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
@@ -437,7 +441,7 @@ html.dark .config-label {
|
||||
.config-value {
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [],
|
||||
allowedHosts: process.env.ALLOWED_HOSTS
|
||||
? process.env.ALLOWED_HOSTS.split(',')
|
||||
: [],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://127.0.0.1:7500',
|
||||
|
||||
3015
web/frps/yarn.lock
3015
web/frps/yarn.lock
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user