commit
5f575b8442
@ -6,6 +6,14 @@ jobs:
|
|||||||
resource_class: large
|
resource_class: large
|
||||||
steps:
|
steps:
|
||||||
- checkout
|
- 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
|
||||||
- run: make alltest
|
- run: make alltest
|
||||||
|
|
||||||
|
|||||||
9
.github/workflows/golangci-lint.yml
vendored
9
.github/workflows/golangci-lint.yml
vendored
@ -19,6 +19,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
go-version: '1.24'
|
||||||
cache: false
|
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
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v8
|
uses: golangci/golangci-lint-action@v8
|
||||||
with:
|
with:
|
||||||
|
|||||||
12
.github/workflows/goreleaser.yml
vendored
12
.github/workflows/goreleaser.yml
vendored
@ -16,13 +16,21 @@ jobs:
|
|||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: '1.24'
|
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
|
- name: Make All
|
||||||
run: |
|
run: |
|
||||||
./package.sh
|
./package.sh
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v5
|
uses: goreleaser/goreleaser-action@v6
|
||||||
with:
|
with:
|
||||||
version: latest
|
version: latest
|
||||||
args: release --clean --release-notes=./Release.md
|
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
|
export GO111MODULE=on
|
||||||
LDFLAGS := -s -w
|
LDFLAGS := -s -w
|
||||||
|
|
||||||
all: env fmt build
|
.PHONY: web frps-web frpc-web frps frpc
|
||||||
|
|
||||||
|
all: env fmt web build
|
||||||
|
|
||||||
build: frps frpc
|
build: frps frpc
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@go version
|
@go version
|
||||||
|
|
||||||
# compile assets into binary file
|
web: frps-web frpc-web
|
||||||
file:
|
|
||||||
rm -rf ./assets/frps/static/*
|
frps-web:
|
||||||
rm -rf ./assets/frpc/static/*
|
$(MAKE) -C web/frps build
|
||||||
cp -rf ./web/frps/dist/* ./assets/frps/static
|
|
||||||
cp -rf ./web/frpc/dist/* ./assets/frpc/static
|
frpc-web:
|
||||||
|
$(MAKE) -C web/frpc build
|
||||||
|
|
||||||
fmt:
|
fmt:
|
||||||
go fmt ./...
|
go fmt ./...
|
||||||
@ -25,7 +28,7 @@ fmt-more:
|
|||||||
gci:
|
gci:
|
||||||
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
|
gci write -s standard -s default -s "prefix(github.com/fatedier/frp/)" ./
|
||||||
|
|
||||||
vet:
|
vet: web
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
frps:
|
frps:
|
||||||
@ -36,7 +39,7 @@ frpc:
|
|||||||
|
|
||||||
test: gotest
|
test: gotest
|
||||||
|
|
||||||
gotest:
|
gotest: web
|
||||||
go test -v --cover ./assets/...
|
go test -v --cover ./assets/...
|
||||||
go test -v --cover ./cmd/...
|
go test -v --cover ./cmd/...
|
||||||
go test -v --cover ./client/...
|
go test -v --cover ./client/...
|
||||||
|
|||||||
29
README.md
29
README.md
@ -13,6 +13,16 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||||
|
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||||
|
<br>
|
||||||
|
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
||||||
|
<br>
|
||||||
|
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@ -30,7 +40,6 @@ frp is an open source project with its ongoing development made possible entirel
|
|||||||
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
<sub>An open source, self-hosted alternative to public clouds, built for data ownership and privacy</sub>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
## Recall.ai - API for meeting recordings
|
## Recall.ai - API for meeting recordings
|
||||||
@ -40,24 +49,6 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
|||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://go.warp.dev/frp" target="_blank">
|
|
||||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
|
||||||
<br>
|
|
||||||
<b>Warp, built for collaborating with AI Agents</b>
|
|
||||||
<br>
|
|
||||||
<sub>Available for macOS, Linux and Windows</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## What is frp?
|
## What is frp?
|
||||||
|
|||||||
28
README_zh.md
28
README_zh.md
@ -15,6 +15,16 @@ frp 是一个完全开源的项目,我们的开发工作完全依靠赞助者
|
|||||||
|
|
||||||
<h3 align="center">Gold Sponsors</h3>
|
<h3 align="center">Gold Sponsors</h3>
|
||||||
<!--gold sponsors start-->
|
<!--gold sponsors start-->
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
||||||
|
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
||||||
|
<br>
|
||||||
|
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
||||||
|
<br>
|
||||||
|
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://jb.gg/frp" target="_blank">
|
<a href="https://jb.gg/frp" target="_blank">
|
||||||
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
<img width="420px" src="https://raw.githubusercontent.com/fatedier/frp/dev/doc/pic/sponsor_jetbrains.jpg">
|
||||||
@ -41,24 +51,6 @@ If you're looking for a meeting recording API, consider checking out [Recall.ai]
|
|||||||
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p align="center">
|
|
||||||
<a href="https://requestly.com/?utm_source=github&utm_medium=partnered&utm_campaign=frp" target="_blank">
|
|
||||||
<img width="480px" src="https://github.com/user-attachments/assets/24670320-997d-4d62-9bca-955c59fe883d">
|
|
||||||
<br>
|
|
||||||
<b>Requestly - Free & Open-Source alternative to Postman</b>
|
|
||||||
<br>
|
|
||||||
<sub>All-in-one platform to Test, Mock and Intercept APIs.</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://go.warp.dev/frp" target="_blank">
|
|
||||||
<img width="360px" src="https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Github/Sponsor/Warp-Github-LG-01.png">
|
|
||||||
<br>
|
|
||||||
<b>Warp, built for collaborating with AI Agents</b>
|
|
||||||
<br>
|
|
||||||
<sub>Available for macOS, Linux and Windows</sub>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<!--gold sponsors end-->
|
<!--gold sponsors end-->
|
||||||
|
|
||||||
## 为什么使用 frp ?
|
## 为什么使用 frp ?
|
||||||
|
|||||||
11
Release.md
11
Release.md
@ -1,13 +1,8 @@
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
* HTTPS proxies now support load balancing groups. Multiple HTTPS proxies can be configured with the same `loadBalancer.group` and `loadBalancer.groupKey` to share the same custom domain and distribute traffic across multiple backend services, similar to the existing TCP and HTTP load balancing capabilities.
|
* 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.
|
||||||
* Individual frpc proxies and visitors now accept an `enabled` flag (defaults to true), letting you disable specific entries without relying on the global `start` list—disabled blocks are skipped when client configs load.
|
* Redesigned the frp web dashboard with a modern UI, dark mode support, and improved navigation.
|
||||||
* OIDC authentication now supports a `tokenSource` field to dynamically obtain tokens from external sources. You can use `type = "file"` to read a token from a file, or `type = "exec"` to run an external command (e.g., a cloud CLI or secrets manager) and capture its stdout as the token. The `exec` type requires the `--allow-unsafe=TokenSourceExec` CLI flag for security reasons.
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
* **VirtualNet**: Implemented intelligent reconnection with exponential backoff. When connection errors occur repeatedly, the reconnect interval increases from 60s to 300s (max), reducing unnecessary reconnection attempts. Normal disconnections still reconnect quickly at 10s intervals.
|
|
||||||
|
|
||||||
## Fixes
|
## Fixes
|
||||||
|
|
||||||
* Fix deadlock issue when TCP connection is closed. Previously, sending messages could block forever if the connection handler had already stopped.
|
* Fixed UDP proxy protocol sending header on every packet instead of only the first packet of each session.
|
||||||
|
|||||||
@ -41,7 +41,7 @@ func Load(path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Register(fileSystem fs.FS) {
|
func Register(fileSystem fs.FS) {
|
||||||
subFs, err := fs.Sub(fileSystem, "static")
|
subFs, err := fs.Sub(fileSystem, "dist")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
content = subFs
|
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 admin UI</title>
|
|
||||||
<script type="module" crossorigin src="./index-bLBhaJo8.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./index-iuf46MlF.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>frps dashboard</title>
|
|
||||||
<script type="module" crossorigin src="./index-82-40HIG.js"></script>
|
|
||||||
<link rel="stylesheet" crossorigin href="./index-rzPDshRD.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@ -15,44 +15,29 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/client/api"
|
||||||
"github.com/fatedier/frp/client/proxy"
|
"github.com/fatedier/frp/client/proxy"
|
||||||
"github.com/fatedier/frp/pkg/config"
|
|
||||||
"github.com/fatedier/frp/pkg/config/v1/validation"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
netpkg "github.com/fatedier/frp/pkg/util/net"
|
||||||
)
|
)
|
||||||
|
|
||||||
type GeneralResponse struct {
|
|
||||||
Code int
|
|
||||||
Msg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
|
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
|
||||||
helper.Router.HandleFunc("/healthz", svr.healthz)
|
apiController := newAPIController(svr)
|
||||||
|
|
||||||
|
// Healthz endpoint without auth
|
||||||
|
helper.Router.HandleFunc("/healthz", healthz)
|
||||||
|
|
||||||
|
// API routes and static files with auth
|
||||||
subRouter := helper.Router.NewRoute().Subrouter()
|
subRouter := helper.Router.NewRoute().Subrouter()
|
||||||
|
subRouter.Use(helper.AuthMiddleware)
|
||||||
subRouter.Use(helper.AuthMiddleware.Middleware)
|
subRouter.Use(httppkg.NewRequestLogger)
|
||||||
|
subRouter.HandleFunc("/api/reload", httppkg.MakeHTTPHandlerFunc(apiController.Reload)).Methods(http.MethodGet)
|
||||||
// api, see admin_api.go
|
subRouter.HandleFunc("/api/stop", httppkg.MakeHTTPHandlerFunc(apiController.Stop)).Methods(http.MethodPost)
|
||||||
subRouter.HandleFunc("/api/reload", svr.apiReload).Methods("GET")
|
subRouter.HandleFunc("/api/status", httppkg.MakeHTTPHandlerFunc(apiController.Status)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/stop", svr.apiStop).Methods("POST")
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.GetConfig)).Methods(http.MethodGet)
|
||||||
subRouter.HandleFunc("/api/status", svr.apiStatus).Methods("GET")
|
subRouter.HandleFunc("/api/config", httppkg.MakeHTTPHandlerFunc(apiController.PutConfig)).Methods(http.MethodPut)
|
||||||
subRouter.HandleFunc("/api/config", svr.apiGetConfig).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/config", svr.apiPutConfig).Methods("PUT")
|
|
||||||
|
|
||||||
// view
|
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
||||||
@ -62,201 +47,28 @@ func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// /healthz
|
func healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
|
w.WriteHeader(http.StatusOK)
|
||||||
w.WriteHeader(200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/reload
|
func newAPIController(svr *Service) *api.Controller {
|
||||||
func (svr *Service) apiReload(w http.ResponseWriter, r *http.Request) {
|
return api.NewController(api.ControllerParams{
|
||||||
res := GeneralResponse{Code: 200}
|
GetProxyStatus: svr.getAllProxyStatus,
|
||||||
strictConfigMode := false
|
ServerAddr: svr.common.ServerAddr,
|
||||||
strictStr := r.URL.Query().Get("strictConfig")
|
ConfigFilePath: svr.configFilePath,
|
||||||
if strictStr != "" {
|
UnsafeFeatures: svr.unsafeFeatures,
|
||||||
strictConfigMode, _ = strconv.ParseBool(strictStr)
|
UpdateConfig: svr.UpdateAllConfigurer,
|
||||||
}
|
GracefulClose: svr.GracefulClose,
|
||||||
|
})
|
||||||
log.Infof("api request [/api/reload]")
|
|
||||||
defer func() {
|
|
||||||
log.Infof("api response [/api/reload], code [%d]", res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(svr.configFilePath, strictConfigMode)
|
|
||||||
if err != nil {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = err.Error()
|
|
||||||
log.Warnf("reload frpc proxy config error: %s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, svr.unsafeFeatures); err != nil {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = err.Error()
|
|
||||||
log.Warnf("reload frpc proxy config error: %s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := svr.UpdateAllConfigurer(proxyCfgs, visitorCfgs); err != nil {
|
|
||||||
res.Code = 500
|
|
||||||
res.Msg = err.Error()
|
|
||||||
log.Warnf("reload frpc proxy config error: %s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Infof("success reload conf")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/stop
|
// getAllProxyStatus returns all proxy statuses.
|
||||||
func (svr *Service) apiStop(w http.ResponseWriter, _ *http.Request) {
|
func (svr *Service) getAllProxyStatus() []*proxy.WorkingStatus {
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
|
|
||||||
log.Infof("api request [/api/stop]")
|
|
||||||
defer func() {
|
|
||||||
log.Infof("api response [/api/stop], code [%d]", res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go svr.GracefulClose(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
type StatusResp map[string][]ProxyStatusResp
|
|
||||||
|
|
||||||
type ProxyStatusResp struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Err string `json:"err"`
|
|
||||||
LocalAddr string `json:"local_addr"`
|
|
||||||
Plugin string `json:"plugin"`
|
|
||||||
RemoteAddr string `json:"remote_addr"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewProxyStatusResp(status *proxy.WorkingStatus, serverAddr string) ProxyStatusResp {
|
|
||||||
psr := ProxyStatusResp{
|
|
||||||
Name: status.Name,
|
|
||||||
Type: status.Type,
|
|
||||||
Status: status.Phase,
|
|
||||||
Err: status.Err,
|
|
||||||
}
|
|
||||||
baseCfg := status.Cfg.GetBaseConfig()
|
|
||||||
if baseCfg.LocalPort != 0 {
|
|
||||||
psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
|
|
||||||
}
|
|
||||||
psr.Plugin = baseCfg.Plugin.Type
|
|
||||||
|
|
||||||
if status.Err == "" {
|
|
||||||
psr.RemoteAddr = status.RemoteAddr
|
|
||||||
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
|
||||||
psr.RemoteAddr = serverAddr + psr.RemoteAddr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return psr
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/status
|
|
||||||
func (svr *Service) apiStatus(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
var (
|
|
||||||
buf []byte
|
|
||||||
res StatusResp = make(map[string][]ProxyStatusResp)
|
|
||||||
)
|
|
||||||
|
|
||||||
log.Infof("http request [/api/status]")
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [/api/status]")
|
|
||||||
buf, _ = json.Marshal(&res)
|
|
||||||
_, _ = w.Write(buf)
|
|
||||||
}()
|
|
||||||
|
|
||||||
svr.ctlMu.RLock()
|
svr.ctlMu.RLock()
|
||||||
ctl := svr.ctl
|
ctl := svr.ctl
|
||||||
svr.ctlMu.RUnlock()
|
svr.ctlMu.RUnlock()
|
||||||
if ctl == nil {
|
if ctl == nil {
|
||||||
return
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
ps := ctl.pm.GetAllProxyStatus()
|
|
||||||
for _, status := range ps {
|
|
||||||
res[status.Type] = append(res[status.Type], NewProxyStatusResp(status, svr.common.ServerAddr))
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, arrs := range res {
|
|
||||||
if len(arrs) <= 1 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
|
|
||||||
return cmp.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /api/config
|
|
||||||
func (svr *Service) apiGetConfig(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
|
|
||||||
log.Infof("http get request [/api/config]")
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http get response [/api/config], code [%d]", res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if svr.configFilePath == "" {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = "frpc has no config file path"
|
|
||||||
log.Warnf("%s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(svr.configFilePath)
|
|
||||||
if err != nil {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = err.Error()
|
|
||||||
log.Warnf("load frpc config file error: %s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res.Msg = string(content)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PUT /api/config
|
|
||||||
func (svr *Service) apiPutConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
|
|
||||||
log.Infof("http put request [/api/config]")
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http put response [/api/config], code [%d]", res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// get new config content
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
if err != nil {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = fmt.Sprintf("read request body error: %v", err)
|
|
||||||
log.Warnf("%s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(body) == 0 {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = "body can't be empty"
|
|
||||||
log.Warnf("%s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(svr.configFilePath, body, 0o600); err != nil {
|
|
||||||
res.Code = 500
|
|
||||||
res.Msg = fmt.Sprintf("write content to frpc config file error: %v", err)
|
|
||||||
log.Warnf("%s", res.Msg)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return ctl.pm.GetAllProxyStatus()
|
||||||
}
|
}
|
||||||
|
|||||||
189
client/api/controller.go
Normal file
189
client/api/controller.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/client/proxy"
|
||||||
|
"github.com/fatedier/frp/pkg/config"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/config/v1/validation"
|
||||||
|
"github.com/fatedier/frp/pkg/policy/security"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Controller handles HTTP API requests for frpc.
|
||||||
|
type Controller struct {
|
||||||
|
// getProxyStatus returns the current proxy status.
|
||||||
|
// Returns nil if the control connection is not established.
|
||||||
|
getProxyStatus func() []*proxy.WorkingStatus
|
||||||
|
|
||||||
|
// serverAddr is the frps server address for display.
|
||||||
|
serverAddr string
|
||||||
|
|
||||||
|
// configFilePath is the path to the configuration file.
|
||||||
|
configFilePath string
|
||||||
|
|
||||||
|
// unsafeFeatures is used for validation.
|
||||||
|
unsafeFeatures *security.UnsafeFeatures
|
||||||
|
|
||||||
|
// updateConfig updates proxy and visitor configurations.
|
||||||
|
updateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
||||||
|
|
||||||
|
// gracefulClose gracefully stops the service.
|
||||||
|
gracefulClose func(d time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ControllerParams contains parameters for creating an APIController.
|
||||||
|
type ControllerParams struct {
|
||||||
|
GetProxyStatus func() []*proxy.WorkingStatus
|
||||||
|
ServerAddr string
|
||||||
|
ConfigFilePath string
|
||||||
|
UnsafeFeatures *security.UnsafeFeatures
|
||||||
|
UpdateConfig func(proxyCfgs []v1.ProxyConfigurer, visitorCfgs []v1.VisitorConfigurer) error
|
||||||
|
GracefulClose func(d time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewController creates a new Controller.
|
||||||
|
func NewController(params ControllerParams) *Controller {
|
||||||
|
return &Controller{
|
||||||
|
getProxyStatus: params.GetProxyStatus,
|
||||||
|
serverAddr: params.ServerAddr,
|
||||||
|
configFilePath: params.ConfigFilePath,
|
||||||
|
unsafeFeatures: params.UnsafeFeatures,
|
||||||
|
updateConfig: params.UpdateConfig,
|
||||||
|
gracefulClose: params.GracefulClose,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload handles GET /api/reload
|
||||||
|
func (c *Controller) Reload(ctx *httppkg.Context) (any, error) {
|
||||||
|
strictConfigMode := false
|
||||||
|
strictStr := ctx.Query("strictConfig")
|
||||||
|
if strictStr != "" {
|
||||||
|
strictConfigMode, _ = strconv.ParseBool(strictStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
cliCfg, proxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(c.configFilePath, strictConfigMode)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := validation.ValidateAllClientConfig(cliCfg, proxyCfgs, visitorCfgs, c.unsafeFeatures); err != nil {
|
||||||
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.updateConfig(proxyCfgs, visitorCfgs); err != nil {
|
||||||
|
log.Warnf("reload frpc proxy config error: %s", err.Error())
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("success reload conf")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop handles POST /api/stop
|
||||||
|
func (c *Controller) Stop(ctx *httppkg.Context) (any, error) {
|
||||||
|
go c.gracefulClose(100 * time.Millisecond)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status handles GET /api/status
|
||||||
|
func (c *Controller) Status(ctx *httppkg.Context) (any, error) {
|
||||||
|
res := make(StatusResp)
|
||||||
|
ps := c.getProxyStatus()
|
||||||
|
if ps == nil {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range ps {
|
||||||
|
res[status.Type] = append(res[status.Type], c.buildProxyStatusResp(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, arrs := range res {
|
||||||
|
if len(arrs) <= 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slices.SortFunc(arrs, func(a, b ProxyStatusResp) int {
|
||||||
|
return cmp.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfig handles GET /api/config
|
||||||
|
func (c *Controller) GetConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
if c.configFilePath == "" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "frpc has no config file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(c.configFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("load frpc config file error: %s", err.Error())
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, err.Error())
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutConfig handles PUT /api/config
|
||||||
|
func (c *Controller) PutConfig(ctx *httppkg.Context) (any, error) {
|
||||||
|
body, err := ctx.Body()
|
||||||
|
if err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, fmt.Sprintf("read request body error: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(body) == 0 {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "body can't be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(c.configFilePath, body, 0o600); err != nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusInternalServerError, fmt.Sprintf("write content to frpc config file error: %v", err))
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildProxyStatusResp creates a ProxyStatusResp from proxy.WorkingStatus
|
||||||
|
func (c *Controller) buildProxyStatusResp(status *proxy.WorkingStatus) ProxyStatusResp {
|
||||||
|
psr := ProxyStatusResp{
|
||||||
|
Name: status.Name,
|
||||||
|
Type: status.Type,
|
||||||
|
Status: status.Phase,
|
||||||
|
Err: status.Err,
|
||||||
|
}
|
||||||
|
baseCfg := status.Cfg.GetBaseConfig()
|
||||||
|
if baseCfg.LocalPort != 0 {
|
||||||
|
psr.LocalAddr = net.JoinHostPort(baseCfg.LocalIP, strconv.Itoa(baseCfg.LocalPort))
|
||||||
|
}
|
||||||
|
psr.Plugin = baseCfg.Plugin.Type
|
||||||
|
|
||||||
|
if status.Err == "" {
|
||||||
|
psr.RemoteAddr = status.RemoteAddr
|
||||||
|
if slices.Contains([]string{"tcp", "udp"}, status.Type) {
|
||||||
|
psr.RemoteAddr = c.serverAddr + psr.RemoteAddr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return psr
|
||||||
|
}
|
||||||
29
client/api/types.go
Normal file
29
client/api/types.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
// StatusResp is the response for GET /api/status
|
||||||
|
type StatusResp map[string][]ProxyStatusResp
|
||||||
|
|
||||||
|
// ProxyStatusResp contains proxy status information
|
||||||
|
type ProxyStatusResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Err string `json:"err"`
|
||||||
|
LocalAddr string `json:"local_addr"`
|
||||||
|
Plugin string `json:"plugin"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
}
|
||||||
@ -281,11 +281,15 @@ func (svr *Service) login() (conn net.Conn, connector Connector, err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
|
||||||
loginMsg := &msg.Login{
|
loginMsg := &msg.Login{
|
||||||
Arch: runtime.GOARCH,
|
Arch: runtime.GOARCH,
|
||||||
Os: runtime.GOOS,
|
Os: runtime.GOOS,
|
||||||
|
Hostname: hostname,
|
||||||
PoolCount: svr.common.Transport.PoolCount,
|
PoolCount: svr.common.Transport.PoolCount,
|
||||||
User: svr.common.User,
|
User: svr.common.User,
|
||||||
|
ClientID: svr.common.ClientID,
|
||||||
Version: version.Full(),
|
Version: version.Full(),
|
||||||
Timestamp: time.Now().Unix(),
|
Timestamp: time.Now().Unix(),
|
||||||
RunID: svr.runID,
|
RunID: svr.runID,
|
||||||
|
|||||||
@ -15,9 +15,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/fatedier/frp/assets/frpc"
|
|
||||||
"github.com/fatedier/frp/cmd/frpc/sub"
|
"github.com/fatedier/frp/cmd/frpc/sub"
|
||||||
"github.com/fatedier/frp/pkg/util/system"
|
"github.com/fatedier/frp/pkg/util/system"
|
||||||
|
_ "github.com/fatedier/frp/web/frpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@ -15,9 +15,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
_ "github.com/fatedier/frp/assets/frps"
|
|
||||||
_ "github.com/fatedier/frp/pkg/metrics"
|
_ "github.com/fatedier/frp/pkg/metrics"
|
||||||
"github.com/fatedier/frp/pkg/util/system"
|
"github.com/fatedier/frp/pkg/util/system"
|
||||||
|
_ "github.com/fatedier/frp/web/frps"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.
|
# This configuration file is for reference only. Please do not use this configuration directly to run the program as it may have various issues.
|
||||||
|
|
||||||
|
# Optional unique identifier for this frpc instance.
|
||||||
|
clientID = "your_client_id"
|
||||||
# your proxy name will be changed to {user}.{proxy}
|
# your proxy name will be changed to {user}.{proxy}
|
||||||
user = "your_name"
|
user = "your_name"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
FROM golang:1.24 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
|
COPY --from=web-builder /web/frpc/dist /building/web/frpc/dist
|
||||||
WORKDIR /building
|
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
|
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
|
FROM golang:1.24 AS building
|
||||||
|
|
||||||
COPY . /building
|
COPY . /building
|
||||||
|
COPY --from=web-builder /web/frps/dist /building/web/frps/dist
|
||||||
WORKDIR /building
|
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
|
FROM alpine:3
|
||||||
|
|
||||||
|
|||||||
@ -167,6 +167,7 @@ func RegisterClientCommonConfigFlags(cmd *cobra.Command, c *v1.ClientCommonConfi
|
|||||||
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
c.Transport.TLS.Enable = cmd.PersistentFlags().BoolP("tls_enable", "", true, "enable frpc tls")
|
||||||
}
|
}
|
||||||
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
cmd.PersistentFlags().StringVarP(&c.User, "user", "u", "", "user")
|
||||||
|
cmd.PersistentFlags().StringVar(&c.ClientID, "client-id", "", "unique identifier for this frpc instance")
|
||||||
cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token")
|
cmd.PersistentFlags().StringVarP(&c.Auth.Token, "token", "t", "", "auth token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,8 @@ type ClientCommonConfig struct {
|
|||||||
// clients. If this value is not "", proxy names will automatically be
|
// clients. If this value is not "", proxy names will automatically be
|
||||||
// changed to "{user}.{proxy_name}".
|
// changed to "{user}.{proxy_name}".
|
||||||
User string `json:"user,omitempty"`
|
User string `json:"user,omitempty"`
|
||||||
|
// ClientID uniquely identifies this frpc instance.
|
||||||
|
ClientID string `json:"clientID,omitempty"`
|
||||||
|
|
||||||
// ServerAddr specifies the address of the server to connect to. By
|
// ServerAddr specifies the address of the server to connect to. By
|
||||||
// default, this value is "0.0.0.0".
|
// default, this value is "0.0.0.0".
|
||||||
|
|||||||
@ -56,9 +56,9 @@ func (m *serverMetrics) CloseClient() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
|
||||||
for _, v := range m.ms {
|
for _, v := range m.ms {
|
||||||
v.NewProxy(name, proxyType)
|
v.NewProxy(name, proxyType, user, clientID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@ func (m *serverMetrics) CloseClient() {
|
|||||||
m.info.ClientCounts.Dec(1)
|
m.info.ClientCounts.Dec(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
func (m *serverMetrics) NewProxy(name string, proxyType string, user string, clientID string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
counter, ok := m.info.ProxyTypeCounts[proxyType]
|
counter, ok := m.info.ProxyTypeCounts[proxyType]
|
||||||
@ -119,6 +119,8 @@ func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
|||||||
}
|
}
|
||||||
m.info.ProxyStatistics[name] = proxyStats
|
m.info.ProxyStatistics[name] = proxyStats
|
||||||
}
|
}
|
||||||
|
proxyStats.User = user
|
||||||
|
proxyStats.ClientID = clientID
|
||||||
proxyStats.LastStartTime = time.Now()
|
proxyStats.LastStartTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -214,6 +216,8 @@ func (m *serverMetrics) GetProxiesByType(proxyType string) []*ProxyStats {
|
|||||||
ps := &ProxyStats{
|
ps := &ProxyStats{
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: proxyStats.ProxyType,
|
Type: proxyStats.ProxyType,
|
||||||
|
User: proxyStats.User,
|
||||||
|
ClientID: proxyStats.ClientID,
|
||||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||||
CurConns: int64(proxyStats.CurConns.Count()),
|
CurConns: int64(proxyStats.CurConns.Count()),
|
||||||
@ -245,6 +249,8 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
res = &ProxyStats{
|
res = &ProxyStats{
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: proxyStats.ProxyType,
|
Type: proxyStats.ProxyType,
|
||||||
|
User: proxyStats.User,
|
||||||
|
ClientID: proxyStats.ClientID,
|
||||||
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||||
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||||
CurConns: int64(proxyStats.CurConns.Count()),
|
CurConns: int64(proxyStats.CurConns.Count()),
|
||||||
@ -260,6 +266,31 @@ func (m *serverMetrics) GetProxiesByTypeAndName(proxyType string, proxyName stri
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *serverMetrics) GetProxyByName(proxyName string) (res *ProxyStats) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
proxyStats, ok := m.info.ProxyStatistics[proxyName]
|
||||||
|
if ok {
|
||||||
|
res = &ProxyStats{
|
||||||
|
Name: proxyName,
|
||||||
|
Type: proxyStats.ProxyType,
|
||||||
|
User: proxyStats.User,
|
||||||
|
ClientID: proxyStats.ClientID,
|
||||||
|
TodayTrafficIn: proxyStats.TrafficIn.TodayCount(),
|
||||||
|
TodayTrafficOut: proxyStats.TrafficOut.TodayCount(),
|
||||||
|
CurConns: int64(proxyStats.CurConns.Count()),
|
||||||
|
}
|
||||||
|
if !proxyStats.LastStartTime.IsZero() {
|
||||||
|
res.LastStartTime = proxyStats.LastStartTime.Format("01-02 15:04:05")
|
||||||
|
}
|
||||||
|
if !proxyStats.LastCloseTime.IsZero() {
|
||||||
|
res.LastCloseTime = proxyStats.LastCloseTime.Format("01-02 15:04:05")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
|
func (m *serverMetrics) GetProxyTraffic(name string) (res *ProxyTrafficInfo) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|||||||
@ -35,6 +35,8 @@ type ServerStats struct {
|
|||||||
type ProxyStats struct {
|
type ProxyStats struct {
|
||||||
Name string
|
Name string
|
||||||
Type string
|
Type string
|
||||||
|
User string
|
||||||
|
ClientID string
|
||||||
TodayTrafficIn int64
|
TodayTrafficIn int64
|
||||||
TodayTrafficOut int64
|
TodayTrafficOut int64
|
||||||
LastStartTime string
|
LastStartTime string
|
||||||
@ -51,6 +53,8 @@ type ProxyTrafficInfo struct {
|
|||||||
type ProxyStatistics struct {
|
type ProxyStatistics struct {
|
||||||
Name string
|
Name string
|
||||||
ProxyType string
|
ProxyType string
|
||||||
|
User string
|
||||||
|
ClientID string
|
||||||
TrafficIn metric.DateCounter
|
TrafficIn metric.DateCounter
|
||||||
TrafficOut metric.DateCounter
|
TrafficOut metric.DateCounter
|
||||||
CurConns metric.Counter
|
CurConns metric.Counter
|
||||||
@ -78,6 +82,7 @@ type Collector interface {
|
|||||||
GetServer() *ServerStats
|
GetServer() *ServerStats
|
||||||
GetProxiesByType(proxyType string) []*ProxyStats
|
GetProxiesByType(proxyType string) []*ProxyStats
|
||||||
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
|
GetProxiesByTypeAndName(proxyType string, proxyName string) *ProxyStats
|
||||||
|
GetProxyByName(proxyName string) *ProxyStats
|
||||||
GetProxyTraffic(name string) *ProxyTrafficInfo
|
GetProxyTraffic(name string) *ProxyTrafficInfo
|
||||||
ClearOfflineProxies() (int, int)
|
ClearOfflineProxies() (int, int)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ func (m *serverMetrics) CloseClient() {
|
|||||||
m.clientCount.Dec()
|
m.clientCount.Dec()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *serverMetrics) NewProxy(name string, proxyType string) {
|
func (m *serverMetrics) NewProxy(name string, proxyType string, _ string, _ string) {
|
||||||
m.proxyCount.WithLabelValues(proxyType).Inc()
|
m.proxyCount.WithLabelValues(proxyType).Inc()
|
||||||
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
|
m.proxyCountDetailed.WithLabelValues(proxyType, name).Inc()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -82,6 +82,7 @@ type Login struct {
|
|||||||
PrivilegeKey string `json:"privilege_key,omitempty"`
|
PrivilegeKey string `json:"privilege_key,omitempty"`
|
||||||
Timestamp int64 `json:"timestamp,omitempty"`
|
Timestamp int64 `json:"timestamp,omitempty"`
|
||||||
RunID string `json:"run_id,omitempty"`
|
RunID string `json:"run_id,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
Metas map[string]string `json:"metas,omitempty"`
|
Metas map[string]string `json:"metas,omitempty"`
|
||||||
|
|
||||||
// Currently only effective for VirtualClient.
|
// Currently only effective for VirtualClient.
|
||||||
|
|||||||
@ -124,8 +124,8 @@ func Forwarder(dstAddr *net.UDPAddr, readCh <-chan *msg.UDPPacket, sendCh chan<-
|
|||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
// Add proxy protocol header if configured
|
// Add proxy protocol header if configured (only for the first packet of a new connection)
|
||||||
if proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
|
if !ok && proxyProtocolVersion != "" && udpMsg.RemoteAddr != nil {
|
||||||
ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)
|
ppBuf, err := netpkg.BuildProxyProtocolHeader(udpMsg.RemoteAddr, dstAddr, proxyProtocolVersion)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Prepend proxy protocol header to the UDP payload
|
// Prepend proxy protocol header to the UDP payload
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/fatedier/frp/client"
|
"github.com/fatedier/frp/client/api"
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -32,7 +32,7 @@ func (c *Client) SetAuth(user, pwd string) {
|
|||||||
c.authPwd = pwd
|
c.authPwd = pwd
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.ProxyStatusResp, error) {
|
func (c *Client) GetProxyStatus(ctx context.Context, name string) (*api.ProxyStatusResp, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -41,7 +41,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.Proxy
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
allStatus := make(client.StatusResp)
|
allStatus := make(api.StatusResp)
|
||||||
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
|
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
|
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ func (c *Client) GetProxyStatus(ctx context.Context, name string) (*client.Proxy
|
|||||||
return nil, fmt.Errorf("no proxy status found")
|
return nil, fmt.Errorf("no proxy status found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) GetAllProxyStatus(ctx context.Context) (client.StatusResp, error) {
|
func (c *Client) GetAllProxyStatus(ctx context.Context) (api.StatusResp, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", "http://"+c.address+"/api/status", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -64,7 +64,7 @@ func (c *Client) GetAllProxyStatus(ctx context.Context) (client.StatusResp, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
allStatus := make(client.StatusResp)
|
allStatus := make(api.StatusResp)
|
||||||
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
|
if err = json.Unmarshal([]byte(content), &allStatus); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
|
return nil, fmt.Errorf("unmarshal http response error: %s", strings.TrimSpace(content))
|
||||||
}
|
}
|
||||||
|
|||||||
57
pkg/util/http/context.go
Normal file
57
pkg/util/http/context.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Context struct {
|
||||||
|
Req *http.Request
|
||||||
|
Resp http.ResponseWriter
|
||||||
|
vars map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContext(w http.ResponseWriter, r *http.Request) *Context {
|
||||||
|
return &Context{
|
||||||
|
Req: r,
|
||||||
|
Resp: w,
|
||||||
|
vars: mux.Vars(r),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Param(key string) string {
|
||||||
|
return c.vars[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Query(key string) string {
|
||||||
|
return c.Req.URL.Query().Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) BindJSON(obj any) error {
|
||||||
|
body, err := io.ReadAll(c.Req.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(body, obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Context) Body() ([]byte, error) {
|
||||||
|
return io.ReadAll(c.Req.Body)
|
||||||
|
}
|
||||||
33
pkg/util/http/error.go
Normal file
33
pkg/util/http/error.go
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
type Error struct {
|
||||||
|
Code int
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return e.Err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewError(code int, msg string) *Error {
|
||||||
|
return &Error{
|
||||||
|
Code: code,
|
||||||
|
Err: fmt.Errorf("%s", msg),
|
||||||
|
}
|
||||||
|
}
|
||||||
66
pkg/util/http/handler.go
Normal file
66
pkg/util/http/handler.go
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeneralResponse struct {
|
||||||
|
Code int
|
||||||
|
Msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIHandler is a handler function that returns a response object or an error.
|
||||||
|
type APIHandler func(ctx *Context) (any, error)
|
||||||
|
|
||||||
|
// MakeHTTPHandlerFunc turns a normal APIHandler into a http.HandlerFunc.
|
||||||
|
func MakeHTTPHandlerFunc(handler APIHandler) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := NewContext(w, r)
|
||||||
|
res, err := handler(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("http response [%s]: error: %v", r.URL.Path, err)
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
if e, ok := err.(*Error); ok {
|
||||||
|
code = e.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(GeneralResponse{Code: code, Msg: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if res == nil {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch v := res.(type) {
|
||||||
|
case []byte:
|
||||||
|
_, _ = w.Write(v)
|
||||||
|
case string:
|
||||||
|
_, _ = w.Write([]byte(v))
|
||||||
|
default:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
40
pkg/util/http/middleware.go
Normal file
40
pkg/util/http/middleware.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type responseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *responseWriter) WriteHeader(code int) {
|
||||||
|
rw.code = code
|
||||||
|
rw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestLogger(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
log.Infof("http request: [%s]", r.URL.Path)
|
||||||
|
rw := &responseWriter{ResponseWriter: w, code: http.StatusOK}
|
||||||
|
next.ServeHTTP(rw, r)
|
||||||
|
log.Infof("http response [%s]: code [%d]", r.URL.Path, rw.code)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
package version
|
package version
|
||||||
|
|
||||||
var version = "0.66.0"
|
var version = "0.67.0"
|
||||||
|
|
||||||
func Full() string {
|
func Full() string {
|
||||||
return version
|
return version
|
||||||
|
|||||||
424
server/api/controller.go
Normal file
424
server/api/controller.go
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatedier/frp/pkg/config/types"
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
"github.com/fatedier/frp/pkg/metrics/mem"
|
||||||
|
httppkg "github.com/fatedier/frp/pkg/util/http"
|
||||||
|
"github.com/fatedier/frp/pkg/util/log"
|
||||||
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
|
"github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Controller struct {
|
||||||
|
// dependencies
|
||||||
|
serverCfg *v1.ServerConfig
|
||||||
|
clientRegistry *registry.ClientRegistry
|
||||||
|
pxyManager ProxyManager
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProxyManager interface {
|
||||||
|
GetByName(name string) (proxy.Proxy, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewController(
|
||||||
|
serverCfg *v1.ServerConfig,
|
||||||
|
clientRegistry *registry.ClientRegistry,
|
||||||
|
pxyManager ProxyManager,
|
||||||
|
) *Controller {
|
||||||
|
return &Controller{
|
||||||
|
serverCfg: serverCfg,
|
||||||
|
clientRegistry: clientRegistry,
|
||||||
|
pxyManager: pxyManager,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/serverinfo
|
||||||
|
func (c *Controller) APIServerInfo(ctx *httppkg.Context) (any, error) {
|
||||||
|
serverStats := mem.StatsCollector.GetServer()
|
||||||
|
svrResp := ServerInfoResp{
|
||||||
|
Version: version.Full(),
|
||||||
|
BindPort: c.serverCfg.BindPort,
|
||||||
|
VhostHTTPPort: c.serverCfg.VhostHTTPPort,
|
||||||
|
VhostHTTPSPort: c.serverCfg.VhostHTTPSPort,
|
||||||
|
TCPMuxHTTPConnectPort: c.serverCfg.TCPMuxHTTPConnectPort,
|
||||||
|
KCPBindPort: c.serverCfg.KCPBindPort,
|
||||||
|
QUICBindPort: c.serverCfg.QUICBindPort,
|
||||||
|
SubdomainHost: c.serverCfg.SubDomainHost,
|
||||||
|
MaxPoolCount: c.serverCfg.Transport.MaxPoolCount,
|
||||||
|
MaxPortsPerClient: c.serverCfg.MaxPortsPerClient,
|
||||||
|
HeartBeatTimeout: c.serverCfg.Transport.HeartbeatTimeout,
|
||||||
|
AllowPortsStr: types.PortsRangeSlice(c.serverCfg.AllowPorts).String(),
|
||||||
|
TLSForce: c.serverCfg.Transport.TLS.Force,
|
||||||
|
|
||||||
|
TotalTrafficIn: serverStats.TotalTrafficIn,
|
||||||
|
TotalTrafficOut: serverStats.TotalTrafficOut,
|
||||||
|
CurConns: serverStats.CurConns,
|
||||||
|
ClientCounts: serverStats.ClientCounts,
|
||||||
|
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
||||||
|
}
|
||||||
|
// For API that returns struct, we can just return it.
|
||||||
|
// But current GeneralResponse.Msg in legacy code expects a JSON string.
|
||||||
|
// Since MakeHTTPHandlerFunc handles struct by encoding to JSON, we can return svrResp directly?
|
||||||
|
// The original code wraps it in GeneralResponse{Msg: string(json)}.
|
||||||
|
// If we return svrResp, the response body will be the JSON of svrResp.
|
||||||
|
// We should check if the frontend expects { "code": 200, "msg": "{...}" } or just {...}.
|
||||||
|
// Looking at previous code:
|
||||||
|
// res := GeneralResponse{Code: 200}
|
||||||
|
// buf, _ := json.Marshal(&svrResp)
|
||||||
|
// res.Msg = string(buf)
|
||||||
|
// Response body: {"code": 200, "msg": "{\"version\":...}"}
|
||||||
|
// Wait, is it double encoded JSON? Yes it seems so!
|
||||||
|
// Let's check dashboard_api.go original code again.
|
||||||
|
// Yes: res.Msg = string(buf).
|
||||||
|
// So the frontend expects { "code": 200, "msg": "JSON_STRING" }.
|
||||||
|
// This is kind of ugly, but we must preserve compatibility.
|
||||||
|
|
||||||
|
return svrResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/clients
|
||||||
|
func (c *Controller) APIClientList(ctx *httppkg.Context) (any, error) {
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
userFilter := ctx.Query("user")
|
||||||
|
clientIDFilter := ctx.Query("clientId")
|
||||||
|
runIDFilter := ctx.Query("runId")
|
||||||
|
statusFilter := strings.ToLower(ctx.Query("status"))
|
||||||
|
|
||||||
|
records := c.clientRegistry.List()
|
||||||
|
items := make([]ClientInfoResp, 0, len(records))
|
||||||
|
for _, info := range records {
|
||||||
|
if userFilter != "" && info.User != userFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if clientIDFilter != "" && info.ClientID() != clientIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if runIDFilter != "" && info.RunID != runIDFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchStatusFilter(info.Online, statusFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, buildClientInfoResp(info))
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(items, func(a, b ClientInfoResp) int {
|
||||||
|
if v := cmp.Compare(a.User, b.User); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
if v := cmp.Compare(a.ClientID, b.ClientID); v != 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.Key, b.Key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/clients/{key}
|
||||||
|
func (c *Controller) APIClientDetail(ctx *httppkg.Context) (any, error) {
|
||||||
|
key := ctx.Param("key")
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("missing client key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.clientRegistry == nil {
|
||||||
|
return nil, fmt.Errorf("client registry unavailable")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := c.clientRegistry.GetByKey(key)
|
||||||
|
if !ok {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, fmt.Sprintf("client %s not found", key))
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildClientInfoResp(info), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/proxy/:type
|
||||||
|
func (c *Controller) APIProxyByType(ctx *httppkg.Context) (any, error) {
|
||||||
|
proxyType := ctx.Param("type")
|
||||||
|
|
||||||
|
proxyInfoResp := GetProxyInfoResp{}
|
||||||
|
proxyInfoResp.Proxies = c.getProxyStatsByType(proxyType)
|
||||||
|
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
|
||||||
|
return cmp.Compare(a.Name, b.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return proxyInfoResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/proxy/:type/:name
|
||||||
|
func (c *Controller) APIProxyByTypeAndName(ctx *httppkg.Context) (any, error) {
|
||||||
|
proxyType := ctx.Param("type")
|
||||||
|
name := ctx.Param("name")
|
||||||
|
|
||||||
|
proxyStatsResp, code, msg := c.getProxyStatsByTypeAndName(proxyType, name)
|
||||||
|
if code != 200 {
|
||||||
|
return nil, httppkg.NewError(code, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyStatsResp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/traffic/:name
|
||||||
|
func (c *Controller) APIProxyTraffic(ctx *httppkg.Context) (any, error) {
|
||||||
|
name := ctx.Param("name")
|
||||||
|
|
||||||
|
trafficResp := GetProxyTrafficResp{}
|
||||||
|
trafficResp.Name = name
|
||||||
|
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
||||||
|
|
||||||
|
if proxyTrafficInfo == nil {
|
||||||
|
return nil, httppkg.NewError(http.StatusNotFound, "no proxy info found")
|
||||||
|
}
|
||||||
|
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
|
||||||
|
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
|
||||||
|
|
||||||
|
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")
|
||||||
|
if status != "offline" {
|
||||||
|
return nil, httppkg.NewError(http.StatusBadRequest, "status only support offline")
|
||||||
|
}
|
||||||
|
cleared, total := mem.StatsCollector.ClearOfflineProxies()
|
||||||
|
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
|
||||||
|
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
|
||||||
|
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
|
||||||
|
for _, ps := range proxyStats {
|
||||||
|
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 {
|
||||||
|
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proxyInfo.Conf = getConfByType(ps.Type)
|
||||||
|
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
||||||
|
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
proxyInfo.Status = "online"
|
||||||
|
c.fillProxyClientInfo(&proxyClientInfo{
|
||||||
|
clientVersion: &proxyInfo.ClientVersion,
|
||||||
|
}, pxy)
|
||||||
|
} else {
|
||||||
|
proxyInfo.Status = "offline"
|
||||||
|
}
|
||||||
|
proxyInfo.Name = ps.Name
|
||||||
|
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
|
||||||
|
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
|
||||||
|
proxyInfo.CurConns = ps.CurConns
|
||||||
|
proxyInfo.LastStartTime = ps.LastStartTime
|
||||||
|
proxyInfo.LastCloseTime = ps.LastCloseTime
|
||||||
|
proxyInfos = append(proxyInfos, proxyInfo)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Controller) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
|
||||||
|
proxyInfo.Name = proxyName
|
||||||
|
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
|
||||||
|
if ps == nil {
|
||||||
|
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 {
|
||||||
|
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||||
|
code = 400
|
||||||
|
msg = "parse conf error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxyInfo.Conf = getConfByType(ps.Type)
|
||||||
|
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
||||||
|
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
||||||
|
code = 400
|
||||||
|
msg = "parse conf error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxyInfo.Status = "online"
|
||||||
|
} else {
|
||||||
|
proxyInfo.Status = "offline"
|
||||||
|
}
|
||||||
|
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
|
||||||
|
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
|
||||||
|
proxyInfo.CurConns = ps.CurConns
|
||||||
|
proxyInfo.LastStartTime = ps.LastStartTime
|
||||||
|
proxyInfo.LastCloseTime = ps.LastCloseTime
|
||||||
|
code = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientInfoResp(info registry.ClientInfo) ClientInfoResp {
|
||||||
|
resp := ClientInfoResp{
|
||||||
|
Key: info.Key,
|
||||||
|
User: info.User,
|
||||||
|
ClientID: info.ClientID(),
|
||||||
|
RunID: info.RunID,
|
||||||
|
Hostname: info.Hostname,
|
||||||
|
ClientIP: info.IP,
|
||||||
|
FirstConnectedAt: toUnix(info.FirstConnectedAt),
|
||||||
|
LastConnectedAt: toUnix(info.LastConnectedAt),
|
||||||
|
Online: info.Online,
|
||||||
|
}
|
||||||
|
if !info.DisconnectedAt.IsZero() {
|
||||||
|
resp.DisconnectedAt = info.DisconnectedAt.Unix()
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return t.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchStatusFilter(online bool, filter string) bool {
|
||||||
|
switch strings.ToLower(filter) {
|
||||||
|
case "", "all":
|
||||||
|
return true
|
||||||
|
case "online":
|
||||||
|
return online
|
||||||
|
case "offline":
|
||||||
|
return !online
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfByType(proxyType string) any {
|
||||||
|
switch v1.ProxyType(proxyType) {
|
||||||
|
case v1.ProxyTypeTCP:
|
||||||
|
return &TCPOutConf{}
|
||||||
|
case v1.ProxyTypeTCPMUX:
|
||||||
|
return &TCPMuxOutConf{}
|
||||||
|
case v1.ProxyTypeUDP:
|
||||||
|
return &UDPOutConf{}
|
||||||
|
case v1.ProxyTypeHTTP:
|
||||||
|
return &HTTPOutConf{}
|
||||||
|
case v1.ProxyTypeHTTPS:
|
||||||
|
return &HTTPSOutConf{}
|
||||||
|
case v1.ProxyTypeSTCP:
|
||||||
|
return &STCPOutConf{}
|
||||||
|
case v1.ProxyTypeXTCP:
|
||||||
|
return &XTCPOutConf{}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
136
server/api/types.go
Normal file
136
server/api/types.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
v1 "github.com/fatedier/frp/pkg/config/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServerInfoResp struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
BindPort int `json:"bindPort"`
|
||||||
|
VhostHTTPPort int `json:"vhostHTTPPort"`
|
||||||
|
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
||||||
|
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
||||||
|
KCPBindPort int `json:"kcpBindPort"`
|
||||||
|
QUICBindPort int `json:"quicBindPort"`
|
||||||
|
SubdomainHost string `json:"subdomainHost"`
|
||||||
|
MaxPoolCount int64 `json:"maxPoolCount"`
|
||||||
|
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
||||||
|
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
|
||||||
|
AllowPortsStr string `json:"allowPortsStr,omitempty"`
|
||||||
|
TLSForce bool `json:"tlsForce,omitempty"`
|
||||||
|
|
||||||
|
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
||||||
|
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
ClientCounts int64 `json:"clientCounts"`
|
||||||
|
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientInfoResp struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
User string `json:"user"`
|
||||||
|
ClientID string `json:"clientID"`
|
||||||
|
RunID string `json:"runID"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
ClientIP string `json:"clientIP,omitempty"`
|
||||||
|
FirstConnectedAt int64 `json:"firstConnectedAt"`
|
||||||
|
LastConnectedAt int64 `json:"lastConnectedAt"`
|
||||||
|
DisconnectedAt int64 `json:"disconnectedAt,omitempty"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseOutConf struct {
|
||||||
|
v1.ProxyBaseConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
RemotePort int `json:"remotePort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TCPMuxOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
v1.DomainConfig
|
||||||
|
Multiplexer string `json:"multiplexer"`
|
||||||
|
RouteByHTTPUser string `json:"routeByHTTPUser"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UDPOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
RemotePort int `json:"remotePort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
v1.DomainConfig
|
||||||
|
Locations []string `json:"locations"`
|
||||||
|
HostHeaderRewrite string `json:"hostHeaderRewrite"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPSOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
v1.DomainConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
type STCPOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
}
|
||||||
|
|
||||||
|
type XTCPOutConf struct {
|
||||||
|
BaseOutConf
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get proxy info.
|
||||||
|
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"`
|
||||||
|
CurConns int64 `json:"curConns"`
|
||||||
|
LastStartTime string `json:"lastStartTime"`
|
||||||
|
LastCloseTime string `json:"lastCloseTime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetProxyInfoResp struct {
|
||||||
|
Proxies []*ProxyStatsInfo `json:"proxies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get proxy info by name.
|
||||||
|
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"`
|
||||||
|
LastStartTime string `json:"lastStartTime"`
|
||||||
|
LastCloseTime string `json:"lastCloseTime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// /api/traffic/:name
|
||||||
|
type GetProxyTrafficResp struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TrafficIn []int64 `json:"trafficIn"`
|
||||||
|
TrafficOut []int64 `json:"trafficOut"`
|
||||||
|
}
|
||||||
@ -40,6 +40,7 @@ import (
|
|||||||
"github.com/fatedier/frp/server/controller"
|
"github.com/fatedier/frp/server/controller"
|
||||||
"github.com/fatedier/frp/server/metrics"
|
"github.com/fatedier/frp/server/metrics"
|
||||||
"github.com/fatedier/frp/server/proxy"
|
"github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ControlManager struct {
|
type ControlManager struct {
|
||||||
@ -147,6 +148,8 @@ type Control struct {
|
|||||||
// Server configuration information
|
// Server configuration information
|
||||||
serverCfg *v1.ServerConfig
|
serverCfg *v1.ServerConfig
|
||||||
|
|
||||||
|
clientRegistry *registry.ClientRegistry
|
||||||
|
|
||||||
xl *xlog.Logger
|
xl *xlog.Logger
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
@ -358,6 +361,7 @@ func (ctl *Control) worker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metrics.Server.CloseClient()
|
metrics.Server.CloseClient()
|
||||||
|
ctl.clientRegistry.MarkOfflineByRunID(ctl.runID)
|
||||||
xl.Infof("client exit success")
|
xl.Infof("client exit success")
|
||||||
close(ctl.doneCh)
|
close(ctl.doneCh)
|
||||||
}
|
}
|
||||||
@ -401,7 +405,11 @@ func (ctl *Control) handleNewProxy(m msg.Message) {
|
|||||||
} else {
|
} else {
|
||||||
resp.RemoteAddr = remoteAddr
|
resp.RemoteAddr = remoteAddr
|
||||||
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
xl.Infof("new proxy [%s] type [%s] success", inMsg.ProxyName, inMsg.ProxyType)
|
||||||
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType)
|
clientID := ctl.loginMsg.ClientID
|
||||||
|
if clientID == "" {
|
||||||
|
clientID = ctl.loginMsg.RunID
|
||||||
|
}
|
||||||
|
metrics.Server.NewProxy(inMsg.ProxyName, inMsg.ProxyType, ctl.loginMsg.User, clientID)
|
||||||
}
|
}
|
||||||
_ = ctl.msgDispatcher.Send(resp)
|
_ = ctl.msgDispatcher.Send(resp)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,406 +0,0 @@
|
|||||||
// Copyright 2017 fatedier, fatedier@gmail.com
|
|
||||||
//
|
|
||||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
// you may not use this file except in compliance with the License.
|
|
||||||
// You may obtain a copy of the License at
|
|
||||||
//
|
|
||||||
// http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
//
|
|
||||||
// Unless required by applicable law or agreed to in writing, software
|
|
||||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
// See the License for the specific language governing permissions and
|
|
||||||
// limitations under the License.
|
|
||||||
|
|
||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cmp"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
|
|
||||||
"github.com/fatedier/frp/pkg/config/types"
|
|
||||||
v1 "github.com/fatedier/frp/pkg/config/v1"
|
|
||||||
"github.com/fatedier/frp/pkg/metrics/mem"
|
|
||||||
httppkg "github.com/fatedier/frp/pkg/util/http"
|
|
||||||
"github.com/fatedier/frp/pkg/util/log"
|
|
||||||
netpkg "github.com/fatedier/frp/pkg/util/net"
|
|
||||||
"github.com/fatedier/frp/pkg/util/version"
|
|
||||||
)
|
|
||||||
|
|
||||||
type GeneralResponse struct {
|
|
||||||
Code int
|
|
||||||
Msg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
|
|
||||||
helper.Router.HandleFunc("/healthz", svr.healthz)
|
|
||||||
subRouter := helper.Router.NewRoute().Subrouter()
|
|
||||||
|
|
||||||
subRouter.Use(helper.AuthMiddleware.Middleware)
|
|
||||||
|
|
||||||
// metrics
|
|
||||||
if svr.cfg.EnablePrometheus {
|
|
||||||
subRouter.Handle("/metrics", promhttp.Handler())
|
|
||||||
}
|
|
||||||
|
|
||||||
// apis
|
|
||||||
subRouter.HandleFunc("/api/serverinfo", svr.apiServerInfo).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/proxy/{type}", svr.apiProxyByType).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/proxy/{type}/{name}", svr.apiProxyByTypeAndName).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/traffic/{name}", svr.apiProxyTraffic).Methods("GET")
|
|
||||||
subRouter.HandleFunc("/api/proxies", svr.deleteProxies).Methods("DELETE")
|
|
||||||
|
|
||||||
// view
|
|
||||||
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
|
||||||
subRouter.PathPrefix("/static/").Handler(
|
|
||||||
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
|
||||||
).Methods("GET")
|
|
||||||
|
|
||||||
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type serverInfoResp struct {
|
|
||||||
Version string `json:"version"`
|
|
||||||
BindPort int `json:"bindPort"`
|
|
||||||
VhostHTTPPort int `json:"vhostHTTPPort"`
|
|
||||||
VhostHTTPSPort int `json:"vhostHTTPSPort"`
|
|
||||||
TCPMuxHTTPConnectPort int `json:"tcpmuxHTTPConnectPort"`
|
|
||||||
KCPBindPort int `json:"kcpBindPort"`
|
|
||||||
QUICBindPort int `json:"quicBindPort"`
|
|
||||||
SubdomainHost string `json:"subdomainHost"`
|
|
||||||
MaxPoolCount int64 `json:"maxPoolCount"`
|
|
||||||
MaxPortsPerClient int64 `json:"maxPortsPerClient"`
|
|
||||||
HeartBeatTimeout int64 `json:"heartbeatTimeout"`
|
|
||||||
AllowPortsStr string `json:"allowPortsStr,omitempty"`
|
|
||||||
TLSForce bool `json:"tlsForce,omitempty"`
|
|
||||||
|
|
||||||
TotalTrafficIn int64 `json:"totalTrafficIn"`
|
|
||||||
TotalTrafficOut int64 `json:"totalTrafficOut"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
ClientCounts int64 `json:"clientCounts"`
|
|
||||||
ProxyTypeCounts map[string]int64 `json:"proxyTypeCount"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// /healthz
|
|
||||||
func (svr *Service) healthz(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.WriteHeader(200)
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/serverinfo
|
|
||||||
func (svr *Service) apiServerInfo(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Infof("http request: [%s]", r.URL.Path)
|
|
||||||
serverStats := mem.StatsCollector.GetServer()
|
|
||||||
svrResp := serverInfoResp{
|
|
||||||
Version: version.Full(),
|
|
||||||
BindPort: svr.cfg.BindPort,
|
|
||||||
VhostHTTPPort: svr.cfg.VhostHTTPPort,
|
|
||||||
VhostHTTPSPort: svr.cfg.VhostHTTPSPort,
|
|
||||||
TCPMuxHTTPConnectPort: svr.cfg.TCPMuxHTTPConnectPort,
|
|
||||||
KCPBindPort: svr.cfg.KCPBindPort,
|
|
||||||
QUICBindPort: svr.cfg.QUICBindPort,
|
|
||||||
SubdomainHost: svr.cfg.SubDomainHost,
|
|
||||||
MaxPoolCount: svr.cfg.Transport.MaxPoolCount,
|
|
||||||
MaxPortsPerClient: svr.cfg.MaxPortsPerClient,
|
|
||||||
HeartBeatTimeout: svr.cfg.Transport.HeartbeatTimeout,
|
|
||||||
AllowPortsStr: types.PortsRangeSlice(svr.cfg.AllowPorts).String(),
|
|
||||||
TLSForce: svr.cfg.Transport.TLS.Force,
|
|
||||||
|
|
||||||
TotalTrafficIn: serverStats.TotalTrafficIn,
|
|
||||||
TotalTrafficOut: serverStats.TotalTrafficOut,
|
|
||||||
CurConns: serverStats.CurConns,
|
|
||||||
ClientCounts: serverStats.ClientCounts,
|
|
||||||
ProxyTypeCounts: serverStats.ProxyTypeCounts,
|
|
||||||
}
|
|
||||||
|
|
||||||
buf, _ := json.Marshal(&svrResp)
|
|
||||||
res.Msg = string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
type BaseOutConf struct {
|
|
||||||
v1.ProxyBaseConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
RemotePort int `json:"remotePort"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type TCPMuxOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
v1.DomainConfig
|
|
||||||
Multiplexer string `json:"multiplexer"`
|
|
||||||
RouteByHTTPUser string `json:"routeByHTTPUser"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type UDPOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
RemotePort int `json:"remotePort"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTPOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
v1.DomainConfig
|
|
||||||
Locations []string `json:"locations"`
|
|
||||||
HostHeaderRewrite string `json:"hostHeaderRewrite"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HTTPSOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
v1.DomainConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
type STCPOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
}
|
|
||||||
|
|
||||||
type XTCPOutConf struct {
|
|
||||||
BaseOutConf
|
|
||||||
}
|
|
||||||
|
|
||||||
func getConfByType(proxyType string) any {
|
|
||||||
switch v1.ProxyType(proxyType) {
|
|
||||||
case v1.ProxyTypeTCP:
|
|
||||||
return &TCPOutConf{}
|
|
||||||
case v1.ProxyTypeTCPMUX:
|
|
||||||
return &TCPMuxOutConf{}
|
|
||||||
case v1.ProxyTypeUDP:
|
|
||||||
return &UDPOutConf{}
|
|
||||||
case v1.ProxyTypeHTTP:
|
|
||||||
return &HTTPOutConf{}
|
|
||||||
case v1.ProxyTypeHTTPS:
|
|
||||||
return &HTTPSOutConf{}
|
|
||||||
case v1.ProxyTypeSTCP:
|
|
||||||
return &STCPOutConf{}
|
|
||||||
case v1.ProxyTypeXTCP:
|
|
||||||
return &XTCPOutConf{}
|
|
||||||
default:
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get proxy info.
|
|
||||||
type ProxyStatsInfo struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Conf any `json:"conf"`
|
|
||||||
ClientVersion string `json:"clientVersion,omitempty"`
|
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
LastStartTime string `json:"lastStartTime"`
|
|
||||||
LastCloseTime string `json:"lastCloseTime"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetProxyInfoResp struct {
|
|
||||||
Proxies []*ProxyStatsInfo `json:"proxies"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/proxy/:type
|
|
||||||
func (svr *Service) apiProxyByType(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
params := mux.Vars(r)
|
|
||||||
proxyType := params["type"]
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Infof("http request: [%s]", r.URL.Path)
|
|
||||||
|
|
||||||
proxyInfoResp := GetProxyInfoResp{}
|
|
||||||
proxyInfoResp.Proxies = svr.getProxyStatsByType(proxyType)
|
|
||||||
slices.SortFunc(proxyInfoResp.Proxies, func(a, b *ProxyStatsInfo) int {
|
|
||||||
return cmp.Compare(a.Name, b.Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
buf, _ := json.Marshal(&proxyInfoResp)
|
|
||||||
res.Msg = string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) getProxyStatsByType(proxyType string) (proxyInfos []*ProxyStatsInfo) {
|
|
||||||
proxyStats := mem.StatsCollector.GetProxiesByType(proxyType)
|
|
||||||
proxyInfos = make([]*ProxyStatsInfo, 0, len(proxyStats))
|
|
||||||
for _, ps := range proxyStats {
|
|
||||||
proxyInfo := &ProxyStatsInfo{}
|
|
||||||
if pxy, ok := svr.pxyManager.GetByName(ps.Name); ok {
|
|
||||||
content, err := json.Marshal(pxy.GetConfigurer())
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
proxyInfo.Conf = getConfByType(ps.Type)
|
|
||||||
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
|
||||||
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
proxyInfo.Status = "online"
|
|
||||||
if pxy.GetLoginMsg() != nil {
|
|
||||||
proxyInfo.ClientVersion = pxy.GetLoginMsg().Version
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
proxyInfo.Status = "offline"
|
|
||||||
}
|
|
||||||
proxyInfo.Name = ps.Name
|
|
||||||
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
|
|
||||||
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
|
|
||||||
proxyInfo.CurConns = ps.CurConns
|
|
||||||
proxyInfo.LastStartTime = ps.LastStartTime
|
|
||||||
proxyInfo.LastCloseTime = ps.LastCloseTime
|
|
||||||
proxyInfos = append(proxyInfos, proxyInfo)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get proxy info by name.
|
|
||||||
type GetProxyStatsResp struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Conf any `json:"conf"`
|
|
||||||
TodayTrafficIn int64 `json:"todayTrafficIn"`
|
|
||||||
TodayTrafficOut int64 `json:"todayTrafficOut"`
|
|
||||||
CurConns int64 `json:"curConns"`
|
|
||||||
LastStartTime string `json:"lastStartTime"`
|
|
||||||
LastCloseTime string `json:"lastCloseTime"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/proxy/:type/:name
|
|
||||||
func (svr *Service) apiProxyByTypeAndName(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
params := mux.Vars(r)
|
|
||||||
proxyType := params["type"]
|
|
||||||
name := params["name"]
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Infof("http request: [%s]", r.URL.Path)
|
|
||||||
|
|
||||||
var proxyStatsResp GetProxyStatsResp
|
|
||||||
proxyStatsResp, res.Code, res.Msg = svr.getProxyStatsByTypeAndName(proxyType, name)
|
|
||||||
if res.Code != 200 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
buf, _ := json.Marshal(&proxyStatsResp)
|
|
||||||
res.Msg = string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) getProxyStatsByTypeAndName(proxyType string, proxyName string) (proxyInfo GetProxyStatsResp, code int, msg string) {
|
|
||||||
proxyInfo.Name = proxyName
|
|
||||||
ps := mem.StatsCollector.GetProxiesByTypeAndName(proxyType, proxyName)
|
|
||||||
if ps == nil {
|
|
||||||
code = 404
|
|
||||||
msg = "no proxy info found"
|
|
||||||
} else {
|
|
||||||
if pxy, ok := svr.pxyManager.GetByName(proxyName); ok {
|
|
||||||
content, err := json.Marshal(pxy.GetConfigurer())
|
|
||||||
if err != nil {
|
|
||||||
log.Warnf("marshal proxy [%s] conf info error: %v", ps.Name, err)
|
|
||||||
code = 400
|
|
||||||
msg = "parse conf error"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxyInfo.Conf = getConfByType(ps.Type)
|
|
||||||
if err = json.Unmarshal(content, &proxyInfo.Conf); err != nil {
|
|
||||||
log.Warnf("unmarshal proxy [%s] conf info error: %v", ps.Name, err)
|
|
||||||
code = 400
|
|
||||||
msg = "parse conf error"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
proxyInfo.Status = "online"
|
|
||||||
} else {
|
|
||||||
proxyInfo.Status = "offline"
|
|
||||||
}
|
|
||||||
proxyInfo.TodayTrafficIn = ps.TodayTrafficIn
|
|
||||||
proxyInfo.TodayTrafficOut = ps.TodayTrafficOut
|
|
||||||
proxyInfo.CurConns = ps.CurConns
|
|
||||||
proxyInfo.LastStartTime = ps.LastStartTime
|
|
||||||
proxyInfo.LastCloseTime = ps.LastCloseTime
|
|
||||||
code = 200
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// /api/traffic/:name
|
|
||||||
type GetProxyTrafficResp struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
TrafficIn []int64 `json:"trafficIn"`
|
|
||||||
TrafficOut []int64 `json:"trafficOut"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svr *Service) apiProxyTraffic(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
params := mux.Vars(r)
|
|
||||||
name := params["name"]
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
log.Infof("http request: [%s]", r.URL.Path)
|
|
||||||
|
|
||||||
trafficResp := GetProxyTrafficResp{}
|
|
||||||
trafficResp.Name = name
|
|
||||||
proxyTrafficInfo := mem.StatsCollector.GetProxyTraffic(name)
|
|
||||||
|
|
||||||
if proxyTrafficInfo == nil {
|
|
||||||
res.Code = 404
|
|
||||||
res.Msg = "no proxy info found"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
trafficResp.TrafficIn = proxyTrafficInfo.TrafficIn
|
|
||||||
trafficResp.TrafficOut = proxyTrafficInfo.TrafficOut
|
|
||||||
|
|
||||||
buf, _ := json.Marshal(&trafficResp)
|
|
||||||
res.Msg = string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DELETE /api/proxies?status=offline
|
|
||||||
func (svr *Service) deleteProxies(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res := GeneralResponse{Code: 200}
|
|
||||||
|
|
||||||
log.Infof("http request: [%s]", r.URL.Path)
|
|
||||||
defer func() {
|
|
||||||
log.Infof("http response [%s]: code [%d]", r.URL.Path, res.Code)
|
|
||||||
w.WriteHeader(res.Code)
|
|
||||||
if len(res.Msg) > 0 {
|
|
||||||
_, _ = w.Write([]byte(res.Msg))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
status := r.URL.Query().Get("status")
|
|
||||||
if status != "offline" {
|
|
||||||
res.Code = 400
|
|
||||||
res.Msg = "status only support offline"
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cleared, total := mem.StatsCollector.ClearOfflineProxies()
|
|
||||||
log.Infof("cleared [%d] offline proxies, total [%d] proxies", cleared, total)
|
|
||||||
}
|
|
||||||
@ -7,7 +7,7 @@ import (
|
|||||||
type ServerMetrics interface {
|
type ServerMetrics interface {
|
||||||
NewClient()
|
NewClient()
|
||||||
CloseClient()
|
CloseClient()
|
||||||
NewProxy(name string, proxyType string)
|
NewProxy(name string, proxyType string, user string, clientID string)
|
||||||
CloseProxy(name string, proxyType string)
|
CloseProxy(name string, proxyType string)
|
||||||
OpenConnection(name string, proxyType string)
|
OpenConnection(name string, proxyType string)
|
||||||
CloseConnection(name string, proxyType string)
|
CloseConnection(name string, proxyType string)
|
||||||
@ -27,11 +27,11 @@ func Register(m ServerMetrics) {
|
|||||||
|
|
||||||
type noopServerMetrics struct{}
|
type noopServerMetrics struct{}
|
||||||
|
|
||||||
func (noopServerMetrics) NewClient() {}
|
func (noopServerMetrics) NewClient() {}
|
||||||
func (noopServerMetrics) CloseClient() {}
|
func (noopServerMetrics) CloseClient() {}
|
||||||
func (noopServerMetrics) NewProxy(string, string) {}
|
func (noopServerMetrics) NewProxy(string, string, string, string) {}
|
||||||
func (noopServerMetrics) CloseProxy(string, string) {}
|
func (noopServerMetrics) CloseProxy(string, string) {}
|
||||||
func (noopServerMetrics) OpenConnection(string, string) {}
|
func (noopServerMetrics) OpenConnection(string, string) {}
|
||||||
func (noopServerMetrics) CloseConnection(string, string) {}
|
func (noopServerMetrics) CloseConnection(string, string) {}
|
||||||
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
|
func (noopServerMetrics) AddTrafficIn(string, string, int64) {}
|
||||||
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
|
func (noopServerMetrics) AddTrafficOut(string, string, int64) {}
|
||||||
|
|||||||
179
server/registry/registry.go
Normal file
179
server/registry/registry.go
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
// Copyright 2025 The frp Authors
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientInfo captures metadata about a connected frpc instance.
|
||||||
|
type ClientInfo struct {
|
||||||
|
Key string
|
||||||
|
User string
|
||||||
|
RawClientID string
|
||||||
|
RunID string
|
||||||
|
Hostname string
|
||||||
|
IP string
|
||||||
|
FirstConnectedAt time.Time
|
||||||
|
LastConnectedAt time.Time
|
||||||
|
DisconnectedAt time.Time
|
||||||
|
Online bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
runIndex map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientRegistry() *ClientRegistry {
|
||||||
|
return &ClientRegistry{
|
||||||
|
clients: make(map[string]*ClientInfo),
|
||||||
|
runIndex: make(map[string]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register stores/updates metadata for a client and returns the registry key plus whether it conflicts with an online client.
|
||||||
|
func (cr *ClientRegistry) Register(user, rawClientID, runID, hostname, remoteAddr string) (key string, conflict bool) {
|
||||||
|
if runID == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveID := rawClientID
|
||||||
|
if effectiveID == "" {
|
||||||
|
effectiveID = runID
|
||||||
|
}
|
||||||
|
key = cr.composeClientKey(user, effectiveID)
|
||||||
|
enforceUnique := rawClientID != ""
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cr.mu.Lock()
|
||||||
|
defer cr.mu.Unlock()
|
||||||
|
|
||||||
|
info, exists := cr.clients[key]
|
||||||
|
if enforceUnique && exists && info.Online && info.RunID != "" && info.RunID != runID {
|
||||||
|
return key, true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
info = &ClientInfo{
|
||||||
|
Key: key,
|
||||||
|
User: user,
|
||||||
|
FirstConnectedAt: now,
|
||||||
|
}
|
||||||
|
cr.clients[key] = info
|
||||||
|
} else if info.RunID != "" {
|
||||||
|
delete(cr.runIndex, info.RunID)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.RawClientID = rawClientID
|
||||||
|
info.RunID = runID
|
||||||
|
info.Hostname = hostname
|
||||||
|
info.IP = remoteAddr
|
||||||
|
if info.FirstConnectedAt.IsZero() {
|
||||||
|
info.FirstConnectedAt = now
|
||||||
|
}
|
||||||
|
info.LastConnectedAt = now
|
||||||
|
info.DisconnectedAt = time.Time{}
|
||||||
|
info.Online = true
|
||||||
|
|
||||||
|
cr.runIndex[runID] = key
|
||||||
|
return key, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkOfflineByRunID marks the client as offline when the corresponding control disconnects.
|
||||||
|
func (cr *ClientRegistry) MarkOfflineByRunID(runID string) {
|
||||||
|
cr.mu.Lock()
|
||||||
|
defer cr.mu.Unlock()
|
||||||
|
|
||||||
|
key, ok := cr.runIndex[runID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info, ok := cr.clients[key]; ok && info.RunID == runID {
|
||||||
|
if info.RawClientID == "" {
|
||||||
|
delete(cr.clients, key)
|
||||||
|
} else {
|
||||||
|
info.RunID = ""
|
||||||
|
info.Online = false
|
||||||
|
now := time.Now()
|
||||||
|
info.DisconnectedAt = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(cr.runIndex, runID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a snapshot of all known clients.
|
||||||
|
func (cr *ClientRegistry) List() []ClientInfo {
|
||||||
|
cr.mu.RLock()
|
||||||
|
defer cr.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make([]ClientInfo, 0, len(cr.clients))
|
||||||
|
for _, info := range cr.clients {
|
||||||
|
result = append(result, *info)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
info, ok := cr.clients[key]
|
||||||
|
if !ok {
|
||||||
|
return ClientInfo{}, false
|
||||||
|
}
|
||||||
|
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 == "":
|
||||||
|
return id
|
||||||
|
case id == "":
|
||||||
|
return user
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s.%s", user, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,6 +28,7 @@ import (
|
|||||||
"github.com/fatedier/golib/crypto"
|
"github.com/fatedier/golib/crypto"
|
||||||
"github.com/fatedier/golib/net/mux"
|
"github.com/fatedier/golib/net/mux"
|
||||||
fmux "github.com/hashicorp/yamux"
|
fmux "github.com/hashicorp/yamux"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
quic "github.com/quic-go/quic-go"
|
quic "github.com/quic-go/quic-go"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
|
|
||||||
@ -47,11 +48,13 @@ import (
|
|||||||
"github.com/fatedier/frp/pkg/util/version"
|
"github.com/fatedier/frp/pkg/util/version"
|
||||||
"github.com/fatedier/frp/pkg/util/vhost"
|
"github.com/fatedier/frp/pkg/util/vhost"
|
||||||
"github.com/fatedier/frp/pkg/util/xlog"
|
"github.com/fatedier/frp/pkg/util/xlog"
|
||||||
|
"github.com/fatedier/frp/server/api"
|
||||||
"github.com/fatedier/frp/server/controller"
|
"github.com/fatedier/frp/server/controller"
|
||||||
"github.com/fatedier/frp/server/group"
|
"github.com/fatedier/frp/server/group"
|
||||||
"github.com/fatedier/frp/server/metrics"
|
"github.com/fatedier/frp/server/metrics"
|
||||||
"github.com/fatedier/frp/server/ports"
|
"github.com/fatedier/frp/server/ports"
|
||||||
"github.com/fatedier/frp/server/proxy"
|
"github.com/fatedier/frp/server/proxy"
|
||||||
|
"github.com/fatedier/frp/server/registry"
|
||||||
"github.com/fatedier/frp/server/visitor"
|
"github.com/fatedier/frp/server/visitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -96,6 +99,9 @@ type Service struct {
|
|||||||
// Manage all controllers
|
// Manage all controllers
|
||||||
ctlManager *ControlManager
|
ctlManager *ControlManager
|
||||||
|
|
||||||
|
// Track logical clients keyed by user.clientID (runID fallback when raw clientID is empty).
|
||||||
|
clientRegistry *registry.ClientRegistry
|
||||||
|
|
||||||
// Manage all proxies
|
// Manage all proxies
|
||||||
pxyManager *proxy.Manager
|
pxyManager *proxy.Manager
|
||||||
|
|
||||||
@ -155,9 +161,10 @@ func NewService(cfg *v1.ServerConfig) (*Service, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
svr := &Service{
|
svr := &Service{
|
||||||
ctlManager: NewControlManager(),
|
ctlManager: NewControlManager(),
|
||||||
pxyManager: proxy.NewManager(),
|
clientRegistry: registry.NewClientRegistry(),
|
||||||
pluginManager: plugin.NewManager(),
|
pxyManager: proxy.NewManager(),
|
||||||
|
pluginManager: plugin.NewManager(),
|
||||||
rc: &controller.ResourceController{
|
rc: &controller.ResourceController{
|
||||||
VisitorManager: visitor.NewManager(),
|
VisitorManager: visitor.NewManager(),
|
||||||
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
TCPPortManager: ports.NewManager("tcp", cfg.ProxyBindAddr, cfg.AllowPorts),
|
||||||
@ -606,10 +613,23 @@ func (svr *Service) RegisterControl(ctlConn net.Conn, loginMsg *msg.Login, inter
|
|||||||
// don't return detailed errors to client
|
// don't return detailed errors to client
|
||||||
return fmt.Errorf("unexpected error when creating new controller")
|
return fmt.Errorf("unexpected error when creating new controller")
|
||||||
}
|
}
|
||||||
|
|
||||||
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
|
if oldCtl := svr.ctlManager.Add(loginMsg.RunID, ctl); oldCtl != nil {
|
||||||
oldCtl.WaitClosed()
|
oldCtl.WaitClosed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remoteAddr := ctlConn.RemoteAddr().String()
|
||||||
|
if host, _, err := net.SplitHostPort(remoteAddr); err == nil {
|
||||||
|
remoteAddr = host
|
||||||
|
}
|
||||||
|
_, conflict := svr.clientRegistry.Register(loginMsg.User, loginMsg.ClientID, loginMsg.RunID, loginMsg.Hostname, remoteAddr)
|
||||||
|
if conflict {
|
||||||
|
svr.ctlManager.Del(loginMsg.RunID, ctl)
|
||||||
|
ctl.Close()
|
||||||
|
return fmt.Errorf("client_id [%s] for user [%s] is already online", loginMsg.ClientID, loginMsg.User)
|
||||||
|
}
|
||||||
|
ctl.clientRegistry = svr.clientRegistry
|
||||||
|
|
||||||
ctl.Start()
|
ctl.Start()
|
||||||
|
|
||||||
// for statistics
|
// for statistics
|
||||||
@ -670,3 +690,42 @@ func (svr *Service) RegisterVisitorConn(visitorConn net.Conn, newMsg *msg.NewVis
|
|||||||
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
return svr.rc.VisitorManager.NewConn(newMsg.ProxyName, visitorConn, newMsg.Timestamp, newMsg.SignKey,
|
||||||
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
|
newMsg.UseEncryption, newMsg.UseCompression, visitorUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svr *Service) registerRouteHandlers(helper *httppkg.RouterRegisterHelper) {
|
||||||
|
helper.Router.HandleFunc("/healthz", healthz)
|
||||||
|
subRouter := helper.Router.NewRoute().Subrouter()
|
||||||
|
|
||||||
|
subRouter.Use(helper.AuthMiddleware)
|
||||||
|
subRouter.Use(httppkg.NewRequestLogger)
|
||||||
|
|
||||||
|
// metrics
|
||||||
|
if svr.cfg.EnablePrometheus {
|
||||||
|
subRouter.Handle("/metrics", promhttp.Handler())
|
||||||
|
}
|
||||||
|
|
||||||
|
apiController := api.NewController(svr.cfg, svr.clientRegistry, svr.pxyManager)
|
||||||
|
|
||||||
|
// apis
|
||||||
|
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")
|
||||||
|
subRouter.HandleFunc("/api/proxies", httppkg.MakeHTTPHandlerFunc(apiController.DeleteProxies)).Methods("DELETE")
|
||||||
|
|
||||||
|
// view
|
||||||
|
subRouter.Handle("/favicon.ico", http.FileServer(helper.AssetsFS)).Methods("GET")
|
||||||
|
subRouter.PathPrefix("/static/").Handler(
|
||||||
|
netpkg.MakeHTTPGzipHandler(http.StripPrefix("/static/", http.FileServer(helper.AssetsFS))),
|
||||||
|
).Methods("GET")
|
||||||
|
|
||||||
|
subRouter.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, "/static/", http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
.PHONY: dist build preview lint
|
.PHONY: dist install build preview lint
|
||||||
|
|
||||||
|
install:
|
||||||
|
@npm install
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@npm run build
|
@npm run build
|
||||||
|
|||||||
16
web/frpc/components.d.ts
vendored
16
web/frpc/components.d.ts
vendored
@ -7,18 +7,22 @@ export {}
|
|||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ClientConfigure: typeof import('./src/components/ClientConfigure.vue')['default']
|
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElInput: typeof import('element-plus/es')['ElInput']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
Overview: typeof import('./src/components/Overview.vue')['default']
|
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
||||||
|
}
|
||||||
|
export interface ComponentCustomProperties {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import (
|
|||||||
"github.com/fatedier/frp/assets"
|
"github.com/fatedier/frp/assets"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed static/*
|
//go:embed dist
|
||||||
var content embed.FS
|
var EmbedFS embed.FS
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
assets.Register(content)
|
assets.Register(EmbedFS)
|
||||||
}
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>frp client admin UI</title>
|
<title>frp client</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
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
@ -11,25 +11,30 @@
|
|||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"element-plus": "^2.5.3",
|
"element-plus": "^2.13.0",
|
||||||
"vue": "^3.4.15",
|
"vue": "^3.5.26",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.7.2",
|
"@rushstack/eslint-patch": "^1.15.0",
|
||||||
"@types/node": "^18.11.12",
|
"@types/node": "24",
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-vue": "^9.21.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "~5.3.3",
|
"sass": "^1.97.2",
|
||||||
|
"terser": "^5.44.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^0.17.5",
|
"unplugin-auto-import": "^0.17.5",
|
||||||
|
"unplugin-element-plus": "^0.11.2",
|
||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^5.0.12",
|
"vite": "^7.3.0",
|
||||||
"vue-tsc": "^1.8.27"
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vue-tsc": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,116 +1,265 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="grid-content header-color">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="brand">
|
<div class="header-top">
|
||||||
<a href="#">frp client</a>
|
<div class="brand-section">
|
||||||
</div>
|
<div class="logo-wrapper">
|
||||||
<div class="dark-switch">
|
<LogoIcon class="logo-icon" />
|
||||||
<el-switch
|
</div>
|
||||||
v-model="darkmodeSwitch"
|
<span class="divider">/</span>
|
||||||
inline-prompt
|
<span class="brand-name">frp</span>
|
||||||
active-text="Dark"
|
<span class="badge client-badge">Client</span>
|
||||||
inactive-text="Light"
|
<span class="badge" v-if="currentRouteName">{{
|
||||||
@change="toggleDark"
|
currentRouteName
|
||||||
style="
|
}}</span>
|
||||||
--el-switch-on-color: #444452;
|
</div>
|
||||||
--el-switch-off-color: #589ef8;
|
|
||||||
"
|
<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>
|
||||||
|
|
||||||
|
<nav class="nav-bar">
|
||||||
|
<router-link to="/" class="nav-link" active-class="active"
|
||||||
|
>Overview</router-link
|
||||||
|
>
|
||||||
|
<router-link to="/configure" class="nav-link" active-class="active"
|
||||||
|
>Configure</router-link
|
||||||
|
>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
|
||||||
<el-row>
|
|
||||||
<el-col id="side-nav" :xs="24" :md="4">
|
|
||||||
<el-menu
|
|
||||||
default-active="1"
|
|
||||||
mode="vertical"
|
|
||||||
theme="light"
|
|
||||||
router="false"
|
|
||||||
@select="handleSelect"
|
|
||||||
>
|
|
||||||
<el-menu-item index="/">Overview</el-menu-item>
|
|
||||||
<el-menu-item index="/configure">Configure</el-menu-item>
|
|
||||||
<el-menu-item index="">Help</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</el-col>
|
|
||||||
|
|
||||||
<el-col :xs="24" :md="20">
|
<main id="content">
|
||||||
<div id="content">
|
<router-view></router-view>
|
||||||
<router-view></router-view>
|
</main>
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</section>
|
|
||||||
<footer></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useDark, useToggle } from '@vueuse/core'
|
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 route = useRoute()
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const darkmodeSwitch = ref(isDark)
|
|
||||||
const toggleDark = useToggle(isDark)
|
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const currentRouteName = computed(() => {
|
||||||
if (key == '') {
|
if (route.path === '/') return 'Overview'
|
||||||
window.open('https://github.com/fatedier/frp')
|
if (route.path === '/configure') return 'Configure'
|
||||||
}
|
return ''
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--header-height: 112px;
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--header-border: #eaeaea;
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--hover-bg: #f5f5f5;
|
||||||
|
--active-link: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--header-bg: rgba(0, 0, 0, 0.8);
|
||||||
|
--header-border: #333;
|
||||||
|
--text-primary: #fff;
|
||||||
|
--text-secondary: #888;
|
||||||
|
--hover-bg: #1a1a1a;
|
||||||
|
--active-link: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0px;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif;
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
#app {
|
||||||
width: 100%;
|
min-height: 100vh;
|
||||||
height: 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--el-bg-color-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-color {
|
.header {
|
||||||
background: #58b7ff;
|
position: sticky;
|
||||||
}
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
html.dark .header-color {
|
background: var(--header-bg);
|
||||||
background: #395c74;
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content {
|
.logo-icon {
|
||||||
margin-top: 20px;
|
width: 32px;
|
||||||
padding-right: 40px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.client-badge {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .badge.client-badge {
|
||||||
|
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand a {
|
.github-link {
|
||||||
color: #fff;
|
width: 26px;
|
||||||
background-color: transparent;
|
height: 26px;
|
||||||
margin-left: 20px;
|
display: flex;
|
||||||
line-height: 25px;
|
align-items: center;
|
||||||
font-size: 25px;
|
justify-content: center;
|
||||||
padding: 15px 15px;
|
border-radius: 50%;
|
||||||
height: 30px;
|
color: var(--text-primary);
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-switch {
|
.nav-link:hover {
|
||||||
display: flex;
|
color: var(--text-primary);
|
||||||
justify-content: flex-end;
|
}
|
||||||
flex-grow: 1;
|
|
||||||
padding-right: 40px;
|
.nav-link.active {
|
||||||
|
color: var(--active-link);
|
||||||
|
border-bottom-color: var(--active-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
18
web/frpc/src/api/frpc.ts
Normal file
18
web/frpc/src/api/frpc.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type { StatusResponse } from '../types/proxy'
|
||||||
|
|
||||||
|
export const getStatus = () => {
|
||||||
|
return http.get<StatusResponse>('/api/status')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getConfig = () => {
|
||||||
|
return http.get<string>('/api/config')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const putConfig = (content: string) => {
|
||||||
|
return http.put<void>('/api/config', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reloadConfig = () => {
|
||||||
|
return http.get<void>('/api/reload')
|
||||||
|
}
|
||||||
92
web/frpc/src/api/http.ts
Normal file
92
web/frpc/src/api/http.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// http.ts - Base HTTP client
|
||||||
|
|
||||||
|
class HTTPError extends Error {
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
|
||||||
|
constructor(status: number, statusText: string, message?: string) {
|
||||||
|
super(message || statusText)
|
||||||
|
this.status = status
|
||||||
|
this.statusText = statusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HTTPError(
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
`HTTP ${response.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty response (e.g. 204 No Content)
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type')
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
return response.text() as unknown as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<T>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<T>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: requestBody,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
delete: <T>(url: string, options?: RequestInit) =>
|
||||||
|
request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
|
}
|
||||||
105
web/frpc/src/assets/css/custom.css
Normal file
105
web/frpc/src/assets/css/custom.css
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
/* Modern Base Styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for Element Plus components */
|
||||||
|
.el-button,
|
||||||
|
.el-card,
|
||||||
|
.el-input,
|
||||||
|
.el-select,
|
||||||
|
.el-tag {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effects */
|
||||||
|
.el-card:hover {
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better form layouts */
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.el-row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-col {
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
180
web/frpc/src/assets/css/dark.css
Normal file
180
web/frpc/src/assets/css/dark.css
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/* 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: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
background-color: #1a1a2e;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode scrollbar */
|
||||||
|
html.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4a4d6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode cards */
|
||||||
|
html.dark .el-card {
|
||||||
|
background-color: #27293d;
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-card__header {
|
||||||
|
border-bottom-color: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode inputs */
|
||||||
|
html.dark .el-input__wrapper {
|
||||||
|
background-color: #27293d;
|
||||||
|
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.el-table__cell {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table tr {
|
||||||
|
background-color: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.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;
|
||||||
|
}
|
||||||
@ -1,5 +0,0 @@
|
|||||||
html.dark {
|
|
||||||
--el-bg-color: #343432;
|
|
||||||
--el-fill-color-blank: #343432;
|
|
||||||
background-color: #343432;
|
|
||||||
}
|
|
||||||
3
web/frpc/src/assets/icons/github.svg
Normal file
3
web/frpc/src/assets/icons/github.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
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 |
@ -1,102 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-row id="head">
|
|
||||||
<el-button type="primary" @click="fetchData">Refresh</el-button>
|
|
||||||
<el-button type="primary" @click="uploadConfig">Upload</el-button>
|
|
||||||
</el-row>
|
|
||||||
<el-input
|
|
||||||
type="textarea"
|
|
||||||
autosize
|
|
||||||
v-model="textarea"
|
|
||||||
placeholder="frpc configure file, can not be empty..."
|
|
||||||
></el-input>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
||||||
|
|
||||||
let textarea = ref('')
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
fetch('/api/config', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.text()
|
|
||||||
})
|
|
||||||
.then((text) => {
|
|
||||||
textarea.value = text
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ElMessage({
|
|
||||||
showClose: true,
|
|
||||||
message: 'Get configure content from frpc failed!',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadConfig = () => {
|
|
||||||
ElMessageBox.confirm(
|
|
||||||
'This operation will upload your frpc configure file content and hot reload it, do you want to continue?',
|
|
||||||
'Notice',
|
|
||||||
{
|
|
||||||
confirmButtonText: 'Yes',
|
|
||||||
cancelButtonText: 'No',
|
|
||||||
type: 'warning',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
if (textarea.value == '') {
|
|
||||||
ElMessage({
|
|
||||||
message: 'Configure content can not be empty!',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/api/config', {
|
|
||||||
credentials: 'include',
|
|
||||||
method: 'PUT',
|
|
||||||
body: textarea.value,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
fetch('/api/reload', { credentials: 'include' })
|
|
||||||
.then(() => {
|
|
||||||
ElMessage({
|
|
||||||
type: 'success',
|
|
||||||
message: 'Success',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
ElMessage({
|
|
||||||
showClose: true,
|
|
||||||
message: 'Reload frpc configure file error, ' + err,
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ElMessage({
|
|
||||||
showClose: true,
|
|
||||||
message: 'Put config to frpc and hot reload failed!',
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
ElMessage({
|
|
||||||
message: 'Canceled',
|
|
||||||
type: 'info',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#head {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<el-row>
|
|
||||||
<el-col :md="24">
|
|
||||||
<div>
|
|
||||||
<el-table
|
|
||||||
:data="status"
|
|
||||||
stripe
|
|
||||||
style="width: 100%"
|
|
||||||
:default-sort="{ prop: 'type', order: 'ascending' }"
|
|
||||||
>
|
|
||||||
<el-table-column
|
|
||||||
prop="name"
|
|
||||||
label="name"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="type"
|
|
||||||
label="type"
|
|
||||||
width="150"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="local_addr"
|
|
||||||
label="local address"
|
|
||||||
width="200"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="plugin"
|
|
||||||
label="plugin"
|
|
||||||
width="200"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="remote_addr"
|
|
||||||
label="remote address"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column
|
|
||||||
prop="status"
|
|
||||||
label="status"
|
|
||||||
width="150"
|
|
||||||
sortable
|
|
||||||
></el-table-column>
|
|
||||||
<el-table-column prop="err" label="info"></el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { ElMessage } from 'element-plus'
|
|
||||||
|
|
||||||
let status = ref<any[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
fetch('/api/status', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
status.value = new Array()
|
|
||||||
for (let key in json) {
|
|
||||||
for (let ps of json[key]) {
|
|
||||||
console.log(ps)
|
|
||||||
status.value.push(ps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
ElMessage({
|
|
||||||
showClose: true,
|
|
||||||
message: 'Get status info from frpc failed!' + err,
|
|
||||||
type: 'warning',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
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>
|
||||||
@ -1,10 +1,10 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import 'element-plus/dist/index.css'
|
|
||||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|
||||||
import './assets/dark.css'
|
import './assets/css/custom.css'
|
||||||
|
import './assets/css/dark.css'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import Overview from '../components/Overview.vue'
|
import Overview from '../views/Overview.vue'
|
||||||
import ClientConfigure from '../components/ClientConfigure.vue'
|
import ClientConfigure from '../views/ClientConfigure.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
|
|||||||
5
web/frpc/src/svg.d.ts
vendored
Normal file
5
web/frpc/src/svg.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.svg?component' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<object, object, unknown>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
12
web/frpc/src/types/proxy.ts
Normal file
12
web/frpc/src/types/proxy.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export interface ProxyStatus {
|
||||||
|
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[]>
|
||||||
33
web/frpc/src/utils/format.ts
Normal file
33
web/frpc/src/utils/format.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
export function formatDistanceToNow(date: Date): string {
|
||||||
|
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000)
|
||||||
|
|
||||||
|
let interval = seconds / 31536000
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' years ago'
|
||||||
|
|
||||||
|
interval = seconds / 2592000
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' months ago'
|
||||||
|
|
||||||
|
interval = seconds / 86400
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' days ago'
|
||||||
|
|
||||||
|
interval = seconds / 3600
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' hours ago'
|
||||||
|
|
||||||
|
interval = seconds / 60
|
||||||
|
if (interval > 1) return Math.floor(interval) + ' minutes ago'
|
||||||
|
|
||||||
|
return Math.floor(seconds) + ' seconds ago'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return '0 B'
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
// Prevent index out of bounds for extremely large numbers
|
||||||
|
const unit = sizes[i] || sizes[sizes.length - 1]
|
||||||
|
const val = bytes / Math.pow(k, i)
|
||||||
|
|
||||||
|
return parseFloat(val.toFixed(2)) + ' ' + unit
|
||||||
|
}
|
||||||
401
web/frpc/src/views/ClientConfigure.vue
Normal file
401
web/frpc/src/views/ClientConfigure.vue
Normal file
@ -0,0 +1,401 @@
|
|||||||
|
<template>
|
||||||
|
<div class="configure-page">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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, Link } from '@element-plus/icons-vue'
|
||||||
|
import { getConfig, putConfig, reloadConfig } from '../api/frpc'
|
||||||
|
|
||||||
|
const configContent = ref('')
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const text = await getConfig()
|
||||||
|
configContent.value = text
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage({
|
||||||
|
showClose: true,
|
||||||
|
message: 'Get configuration failed: ' + err.message,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
ElMessageBox.confirm(
|
||||||
|
'This operation will update your frpc configuration and reload it. Do you want to continue?',
|
||||||
|
'Confirm Update',
|
||||||
|
{
|
||||||
|
confirmButtonText: 'Update',
|
||||||
|
cancelButtonText: 'Cancel',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(async () => {
|
||||||
|
if (!configContent.value.trim()) {
|
||||||
|
ElMessage({
|
||||||
|
message: 'Configuration content cannot be empty!',
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putConfig(configContent.value)
|
||||||
|
await reloadConfig()
|
||||||
|
ElMessage({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Configuration updated and reloaded successfully',
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage({
|
||||||
|
showClose: true,
|
||||||
|
message: 'Update failed: ' + err.message,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// cancelled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.configure-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-card,
|
||||||
|
.help-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 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;
|
||||||
|
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 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card-title {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
442
web/frpc/src/views/Overview.vue
Normal file
442
web/frpc/src/views/Overview.vue
Normal file
@ -0,0 +1,442 @@
|
|||||||
|
<template>
|
||||||
|
<div class="overview-page">
|
||||||
|
<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-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>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
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
|
||||||
|
}
|
||||||
|
const search = searchText.value.toLowerCase()
|
||||||
|
return status.value.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(search) ||
|
||||||
|
p.type.toLowerCase().includes(search) ||
|
||||||
|
p.local_addr.toLowerCase().includes(search) ||
|
||||||
|
p.remote_addr.toLowerCase().includes(search),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const json = await getStatus()
|
||||||
|
status.value = []
|
||||||
|
for (const key in json) {
|
||||||
|
for (const ps of json[key]) {
|
||||||
|
status.value.push(ps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
ElMessage({
|
||||||
|
showClose: true,
|
||||||
|
message: 'Get status info from frpc failed! ' + err.message,
|
||||||
|
type: 'warning',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.overview-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 1px solid #e4e7ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .proxy-list-card,
|
||||||
|
html.dark .types-card,
|
||||||
|
html.dark .status-summary-card {
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
background: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-summary-card {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #303133;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .card-title {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-list-content {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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) {
|
||||||
|
.card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-types-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.status-summary-card {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,15 +2,19 @@ import { fileURLToPath, URL } from 'node:url'
|
|||||||
|
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import svgLoader from 'vite-svg-loader'
|
||||||
import AutoImport from 'unplugin-auto-import/vite'
|
import AutoImport from 'unplugin-auto-import/vite'
|
||||||
import Components from 'unplugin-vue-components/vite'
|
import Components from 'unplugin-vue-components/vite'
|
||||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
import ElementPlus from 'unplugin-element-plus/vite'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '',
|
base: '',
|
||||||
plugins: [
|
plugins: [
|
||||||
vue(),
|
vue(),
|
||||||
|
svgLoader(),
|
||||||
|
ElementPlus({}),
|
||||||
AutoImport({
|
AutoImport({
|
||||||
resolvers: [ElementPlusResolver()],
|
resolvers: [ElementPlusResolver()],
|
||||||
}),
|
}),
|
||||||
@ -25,5 +29,24 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
assetsDir: '',
|
assetsDir: '',
|
||||||
|
chunkSizeWarningLimit: 1000,
|
||||||
|
minify: 'terser',
|
||||||
|
terserOptions: {
|
||||||
|
compress: {
|
||||||
|
drop_console: true,
|
||||||
|
drop_debugger: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
allowedHosts: process.env.ALLOWED_HOSTS
|
||||||
|
? process.env.ALLOWED_HOSTS.split(',')
|
||||||
|
: [],
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
2603
web/frpc/yarn.lock
2603
web/frpc/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -23,7 +23,7 @@ module.exports = {
|
|||||||
'vue/multi-word-component-names': [
|
'vue/multi-word-component-names': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
ignores: ['Traffic'],
|
ignores: ['Traffic', 'Proxies', 'Clients'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
.PHONY: dist build preview lint
|
.PHONY: dist install build preview lint
|
||||||
|
|
||||||
|
install:
|
||||||
|
@npm install
|
||||||
|
|
||||||
build:
|
build:
|
||||||
@npm run build
|
@npm run build
|
||||||
|
|||||||
34
web/frps/components.d.ts
vendored
34
web/frps/components.d.ts
vendored
@ -7,37 +7,27 @@ export {}
|
|||||||
|
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
|
ClientCard: typeof import('./src/components/ClientCard.vue')['default']
|
||||||
ElButton: typeof import('element-plus/es')['ElButton']
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElCard: typeof import('element-plus/es')['ElCard']
|
||||||
ElCol: typeof import('element-plus/es')['ElCol']
|
ElCol: typeof import('element-plus/es')['ElCol']
|
||||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
ElForm: typeof import('element-plus/es')['ElForm']
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
|
||||||
ElPageHeader: typeof import('element-plus/es')['ElPageHeader']
|
|
||||||
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
ElPopconfirm: typeof import('element-plus/es')['ElPopconfirm']
|
||||||
ElRow: typeof import('element-plus/es')['ElRow']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
ElTable: typeof import('element-plus/es')['ElTable']
|
|
||||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
|
||||||
ElTag: typeof import('element-plus/es')['ElTag']
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
ElText: typeof import('element-plus/es')['ElText']
|
|
||||||
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
ElTooltip: typeof import('element-plus/es')['ElTooltip']
|
||||||
LongSpan: typeof import('./src/components/LongSpan.vue')['default']
|
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
|
||||||
ProxiesHTTP: typeof import('./src/components/ProxiesHTTP.vue')['default']
|
|
||||||
ProxiesHTTPS: typeof import('./src/components/ProxiesHTTPS.vue')['default']
|
|
||||||
ProxiesSTCP: typeof import('./src/components/ProxiesSTCP.vue')['default']
|
|
||||||
ProxiesSUDP: typeof import('./src/components/ProxiesSUDP.vue')['default']
|
|
||||||
ProxiesTCP: typeof import('./src/components/ProxiesTCP.vue')['default']
|
|
||||||
ProxiesTCPMux: typeof import('./src/components/ProxiesTCPMux.vue')['default']
|
|
||||||
ProxiesUDP: typeof import('./src/components/ProxiesUDP.vue')['default']
|
|
||||||
ProxyView: typeof import('./src/components/ProxyView.vue')['default']
|
|
||||||
ProxyViewExpand: typeof import('./src/components/ProxyViewExpand.vue')['default']
|
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
ServerOverview: typeof import('./src/components/ServerOverview.vue')['default']
|
StatCard: typeof import('./src/components/StatCard.vue')['default']
|
||||||
Traffic: typeof import('./src/components/Traffic.vue')['default']
|
Traffic: typeof import('./src/components/Traffic.vue')['default']
|
||||||
}
|
}
|
||||||
|
export interface ComponentCustomProperties {
|
||||||
|
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>frps dashboard</title>
|
<title>frp server</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
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
@ -11,28 +11,30 @@
|
|||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/humanize-plus": "^1.8.0",
|
"element-plus": "^2.13.0",
|
||||||
"echarts": "^5.4.3",
|
"vue": "^3.5.26",
|
||||||
"element-plus": "^2.5.3",
|
"vue-router": "^4.6.4"
|
||||||
"humanize-plus": "^1.8.2",
|
|
||||||
"vue": "^3.4.15",
|
|
||||||
"vue-router": "^4.2.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.7.2",
|
"@rushstack/eslint-patch": "^1.15.0",
|
||||||
"@types/node": "^18.11.12",
|
"@types/node": "24",
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vue/eslint-config-prettier": "^9.0.0",
|
"@vue/eslint-config-prettier": "^9.0.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "^12.0.0",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-vue": "^9.21.0",
|
"eslint-plugin-vue": "^9.33.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.2.4",
|
"prettier": "^3.7.4",
|
||||||
"typescript": "~5.3.3",
|
"sass": "^1.97.2",
|
||||||
|
"terser": "^5.44.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
"unplugin-auto-import": "^0.17.5",
|
"unplugin-auto-import": "^0.17.5",
|
||||||
|
"unplugin-element-plus": "^0.11.2",
|
||||||
"unplugin-vue-components": "^0.26.0",
|
"unplugin-vue-components": "^0.26.0",
|
||||||
"vite": "^5.0.12",
|
"vite": "^7.3.0",
|
||||||
"vue-tsc": "^1.8.27"
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vue-tsc": "^3.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,127 +1,272 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="grid-content header-color">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<div class="brand">
|
<div class="header-top">
|
||||||
<a href="#">frp</a>
|
<div class="brand-section">
|
||||||
</div>
|
<div class="logo-wrapper">
|
||||||
<div class="dark-switch">
|
<LogoIcon class="logo-icon" />
|
||||||
<el-switch
|
</div>
|
||||||
v-model="darkmodeSwitch"
|
<span class="divider">/</span>
|
||||||
inline-prompt
|
<span class="brand-name">frp</span>
|
||||||
active-text="Dark"
|
<span class="badge server-badge">Server</span>
|
||||||
inactive-text="Light"
|
<span class="badge" v-if="currentRouteName">{{
|
||||||
@change="toggleDark"
|
currentRouteName
|
||||||
style="
|
}}</span>
|
||||||
--el-switch-on-color: #444452;
|
</div>
|
||||||
--el-switch-off-color: #589ef8;
|
|
||||||
"
|
<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>
|
||||||
|
|
||||||
|
<nav class="nav-bar">
|
||||||
|
<router-link to="/" class="nav-link" active-class="active"
|
||||||
|
>Overview</router-link
|
||||||
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<section>
|
|
||||||
<el-row>
|
|
||||||
<el-col id="side-nav" :xs="24" :md="4">
|
|
||||||
<el-menu
|
|
||||||
default-active="/"
|
|
||||||
mode="vertical"
|
|
||||||
theme="light"
|
|
||||||
router="false"
|
|
||||||
@select="handleSelect"
|
|
||||||
>
|
|
||||||
<el-menu-item index="/">Overview</el-menu-item>
|
|
||||||
<el-sub-menu index="/proxies">
|
|
||||||
<template #title>
|
|
||||||
<span>Proxies</span>
|
|
||||||
</template>
|
|
||||||
<el-menu-item index="/proxies/tcp">TCP</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/udp">UDP</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/http">HTTP</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/https">HTTPS</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/tcpmux">TCPMUX</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/stcp">STCP</el-menu-item>
|
|
||||||
<el-menu-item index="/proxies/sudp">SUDP</el-menu-item>
|
|
||||||
</el-sub-menu>
|
|
||||||
<el-menu-item index="">Help</el-menu-item>
|
|
||||||
</el-menu>
|
|
||||||
</el-col>
|
|
||||||
|
|
||||||
<el-col :xs="24" :md="20">
|
<main id="content">
|
||||||
<div id="content">
|
<router-view></router-view>
|
||||||
<router-view></router-view>
|
</main>
|
||||||
</div>
|
|
||||||
</el-col>
|
|
||||||
</el-row>
|
|
||||||
</section>
|
|
||||||
<footer></footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useDark, useToggle } from '@vueuse/core'
|
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 route = useRoute()
|
||||||
const isDark = useDark()
|
const isDark = useDark()
|
||||||
const darkmodeSwitch = ref(isDark)
|
|
||||||
const toggleDark = useToggle(isDark)
|
|
||||||
|
|
||||||
const handleSelect = (key: string) => {
|
const currentRouteName = computed(() => {
|
||||||
if (key == '') {
|
if (route.path === '/') return 'Overview'
|
||||||
window.open('https://github.com/fatedier/frp')
|
if (route.path.startsWith('/clients')) return 'Clients'
|
||||||
}
|
if (route.path.startsWith('/proxies')) return 'Proxies'
|
||||||
}
|
return ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
:root {
|
||||||
|
--header-height: 112px;
|
||||||
|
--header-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--header-border: #eaeaea;
|
||||||
|
--text-primary: #000;
|
||||||
|
--text-secondary: #666;
|
||||||
|
--hover-bg: #f5f5f5;
|
||||||
|
--active-link: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
--header-bg: rgba(0, 0, 0, 0.8);
|
||||||
|
--header-border: #333;
|
||||||
|
--text-primary: #fff;
|
||||||
|
--text-secondary: #888;
|
||||||
|
--hover-bg: #1a1a1a;
|
||||||
|
--active-link: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0px;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, sans-serif;
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
header {
|
#app {
|
||||||
width: 100%;
|
min-height: 100vh;
|
||||||
height: 60px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--el-bg-color-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-color {
|
.header {
|
||||||
background: #58b7ff;
|
position: sticky;
|
||||||
}
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
html.dark .header-color {
|
background: var(--header-bg);
|
||||||
background: #395c74;
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--header-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-content {
|
.header-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
height: 64px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#content {
|
.logo-icon {
|
||||||
margin-top: 20px;
|
width: 32px;
|
||||||
padding-right: 40px;
|
height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--hover-bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 99px;
|
||||||
|
border: 1px solid var(--header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.server-badge {
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .badge.server-badge {
|
||||||
|
background: linear-gradient(135deg, #60a5fa 0%, #22d3ee 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand a {
|
.github-link {
|
||||||
color: #fff;
|
width: 26px;
|
||||||
background-color: transparent;
|
height: 26px;
|
||||||
margin-left: 20px;
|
display: flex;
|
||||||
line-height: 25px;
|
align-items: center;
|
||||||
font-size: 25px;
|
justify-content: center;
|
||||||
padding: 15px 15px;
|
border-radius: 50%;
|
||||||
height: 30px;
|
color: var(--text-primary);
|
||||||
|
transition: background 0.2s;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.github-icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
height: 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark-switch {
|
.nav-link:hover {
|
||||||
display: flex;
|
color: var(--text-primary);
|
||||||
justify-content: flex-end;
|
}
|
||||||
flex-grow: 1;
|
|
||||||
padding-right: 40px;
|
.nav-link.active {
|
||||||
|
color: var(--active-link);
|
||||||
|
border-bottom-color: var(--active-link);
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 40px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-content {
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
10
web/frps/src/api/client.ts
Normal file
10
web/frps/src/api/client.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type { ClientInfoData } from '../types/client'
|
||||||
|
|
||||||
|
export const getClients = () => {
|
||||||
|
return http.get<ClientInfoData[]>('../api/clients')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getClient = (key: string) => {
|
||||||
|
return http.get<ClientInfoData>(`../api/clients/${key}`)
|
||||||
|
}
|
||||||
56
web/frps/src/api/http.ts
Normal file
56
web/frps/src/api/http.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// http.ts - Base HTTP client
|
||||||
|
|
||||||
|
class HTTPError extends Error {
|
||||||
|
status: number
|
||||||
|
statusText: string
|
||||||
|
|
||||||
|
constructor(status: number, statusText: string, message?: string) {
|
||||||
|
super(message || statusText)
|
||||||
|
this.status = status
|
||||||
|
this.statusText = statusText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const defaultOptions: RequestInit = {
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, { ...defaultOptions, ...options })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new HTTPError(
|
||||||
|
response.status,
|
||||||
|
response.statusText,
|
||||||
|
`HTTP ${response.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty response (e.g. 204 No Content)
|
||||||
|
if (response.status === 204) {
|
||||||
|
return {} as T
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const http = {
|
||||||
|
get: <T>(url: string, options?: RequestInit) =>
|
||||||
|
request<T>(url, { ...options, method: 'GET' }),
|
||||||
|
post: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
|
request<T>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
put: <T>(url: string, body?: any, options?: RequestInit) =>
|
||||||
|
request<T>(url, {
|
||||||
|
...options,
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
delete: <T>(url: string, options?: RequestInit) =>
|
||||||
|
request<T>(url, { ...options, method: 'DELETE' }),
|
||||||
|
}
|
||||||
26
web/frps/src/api/proxy.ts
Normal file
26
web/frps/src/api/proxy.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type {
|
||||||
|
GetProxyResponse,
|
||||||
|
ProxyStatsInfo,
|
||||||
|
TrafficResponse,
|
||||||
|
} from '../types/proxy'
|
||||||
|
|
||||||
|
export const getProxiesByType = (type: string) => {
|
||||||
|
return http.get<GetProxyResponse>(`../api/proxy/${type}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProxy = (type: string, name: string) => {
|
||||||
|
return http.get<ProxyStatsInfo>(`../api/proxy/${type}/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProxyByName = (name: string) => {
|
||||||
|
return http.get<ProxyStatsInfo>(`../api/proxies/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProxyTraffic = (name: string) => {
|
||||||
|
return http.get<TrafficResponse>(`../api/traffic/${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearOfflineProxies = () => {
|
||||||
|
return http.delete('../api/proxies?status=offline')
|
||||||
|
}
|
||||||
6
web/frps/src/api/server.ts
Normal file
6
web/frps/src/api/server.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
import type { ServerInfo } from '../types/server'
|
||||||
|
|
||||||
|
export const getServerInfo = () => {
|
||||||
|
return http.get<ServerInfo>('../api/serverinfo')
|
||||||
|
}
|
||||||
89
web/frps/src/assets/css/custom.css
Normal file
89
web/frps/src/assets/css/custom.css
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
.el-form-item span {
|
||||||
|
margin-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-table-expand {
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-table-expand .el-form-item__label{
|
||||||
|
width: 90px;
|
||||||
|
color: #99a9bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-table-expand .el-form-item {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-table .el-table__expanded-cell {
|
||||||
|
padding: 20px 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.el-button,
|
||||||
|
.el-card,
|
||||||
|
.el-input,
|
||||||
|
.el-select,
|
||||||
|
.el-tag {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effects */
|
||||||
|
.el-card:hover {
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page headers */
|
||||||
|
.el-page-header {
|
||||||
|
padding: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-page-header__title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better form layouts */
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.el-row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-col {
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
web/frps/src/assets/css/dark.css
Normal file
58
web/frps/src/assets/css/dark.css
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
html.dark {
|
||||||
|
--el-bg-color: #1e1e2e;
|
||||||
|
--el-fill-color-blank: #1e1e2e;
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode scrollbar */
|
||||||
|
html.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4a4d6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode cards */
|
||||||
|
html.dark .el-card {
|
||||||
|
background-color: #27293d;
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode inputs */
|
||||||
|
html.dark .el-input__wrapper {
|
||||||
|
background-color: #27293d;
|
||||||
|
border-color: #3a3d5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-input__inner {
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode table */
|
||||||
|
html.dark .el-table {
|
||||||
|
background-color: #27293d;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table th {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table tr {
|
||||||
|
background-color: #27293d;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td {
|
||||||
|
background-color: #1e1e2e;
|
||||||
|
}
|
||||||
@ -1,22 +0,0 @@
|
|||||||
.el-form-item span {
|
|
||||||
margin-left: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-table-expand {
|
|
||||||
font-size: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-table-expand .el-form-item__label{
|
|
||||||
width: 90px;
|
|
||||||
color: #99a9bf;
|
|
||||||
}
|
|
||||||
|
|
||||||
.proxy-table-expand .el-form-item {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-bottom: 0;
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-table .el-table__expanded-cell {
|
|
||||||
padding: 20px 50px;
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
html.dark {
|
|
||||||
--el-bg-color: #343432;
|
|
||||||
--el-fill-color-blank: #343432;
|
|
||||||
background-color: #343432;
|
|
||||||
}
|
|
||||||
3
web/frps/src/assets/icons/github.svg
Normal file
3
web/frps/src/assets/icons/github.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 671 B |
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 |
258
web/frps/src/components/ClientCard.vue
Normal file
258
web/frps/src/components/ClientCard.vue
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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' }}
|
||||||
|
</div>
|
||||||
|
<el-icon class="arrow-icon"><ArrowRight /></el-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { DataLine, ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
import type { Client } from '../utils/client'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: Client
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const viewDetail = () => {
|
||||||
|
router.push({
|
||||||
|
name: 'ClientDetail',
|
||||||
|
params: { key: props.client.key },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.client-card {
|
||||||
|
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 24px rgba(0, 0, 0, 0.04);
|
||||||
|
border-color: var(--el-border-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrapper {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-dot-large.online {
|
||||||
|
background-color: var(--el-color-success);
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-success-light-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot-large.offline {
|
||||||
|
background-color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity .meta-value {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.online {
|
||||||
|
background: var(--el-color-success-light-9);
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.offline {
|
||||||
|
background: var(--el-fill-color);
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-card:hover .arrow-icon {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
html.dark .card-icon-wrapper {
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .client-card:hover .card-icon-wrapper {
|
||||||
|
background: var(--el-color-success-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .status-dot-large.online {
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--el-color-success-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.client-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon-wrapper {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-content {
|
||||||
|
width: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--el-border-color-lighter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-tooltip :content="content" placement="top">
|
|
||||||
<span v-show="content.length > length"
|
|
||||||
>{{ content.slice(0, length) }}...</span
|
|
||||||
>
|
|
||||||
</el-tooltip>
|
|
||||||
<span v-show="content.length < 30">{{ content }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
content: string
|
|
||||||
length: number
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="http" @refresh="fetchData"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { HTTPProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<HTTPProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
let vhostHTTPPort: number
|
|
||||||
let subdomainHost: string
|
|
||||||
fetch('../api/serverinfo', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
vhostHTTPPort = json.vhostHTTPPort
|
|
||||||
subdomainHost = json.subdomainHost
|
|
||||||
if (vhostHTTPPort == null || vhostHTTPPort == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetch('../api/proxy/http', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(
|
|
||||||
new HTTPProxy(proxyStats, vhostHTTPPort, subdomainHost)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="https" @refresh="fetchData"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { HTTPSProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<HTTPSProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
let vhostHTTPSPort: number
|
|
||||||
let subdomainHost: string
|
|
||||||
fetch('../api/serverinfo', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
vhostHTTPSPort = json.vhostHTTPSPort
|
|
||||||
subdomainHost = json.subdomainHost
|
|
||||||
if (vhostHTTPSPort == null || vhostHTTPSPort == 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fetch('../api/proxy/https', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(
|
|
||||||
new HTTPSProxy(proxyStats, vhostHTTPSPort, subdomainHost)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="stcp" @refresh="fetchData"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { STCPProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<STCPProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
fetch('../api/proxy/stcp', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(new STCPProxy(proxyStats))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="sudp" @refresh="fetchData"/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { SUDPProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<SUDPProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
fetch('../api/proxy/sudp', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(new SUDPProxy(proxyStats))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="tcp" @refresh="fetchData" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { TCPProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<TCPProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
fetch('../api/proxy/tcp', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(new TCPProxy(proxyStats))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ProxyView :proxies="proxies" proxyType="tcpmux" @refresh="fetchData" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { TCPMuxProxy } from '../utils/proxy.js'
|
|
||||||
import ProxyView from './ProxyView.vue'
|
|
||||||
|
|
||||||
let proxies = ref<TCPMuxProxy[]>([])
|
|
||||||
|
|
||||||
const fetchData = () => {
|
|
||||||
let tcpmuxHTTPConnectPort: number
|
|
||||||
let subdomainHost: string
|
|
||||||
fetch('../api/serverinfo', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
tcpmuxHTTPConnectPort = json.tcpmuxHTTPConnectPort
|
|
||||||
subdomainHost = json.subdomainHost
|
|
||||||
|
|
||||||
fetch('../api/proxy/tcpmux', { credentials: 'include' })
|
|
||||||
.then((res) => {
|
|
||||||
return res.json()
|
|
||||||
})
|
|
||||||
.then((json) => {
|
|
||||||
proxies.value = []
|
|
||||||
for (let proxyStats of json.proxies) {
|
|
||||||
proxies.value.push(new TCPMuxProxy(proxyStats, tcpmuxHTTPConnectPort, subdomainHost))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user