mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Merge pull request #75 from Sagit-chu/opencode/shiny-squid
feat(federation): add share flow reset and remote usage visibility
This commit is contained in:
@@ -21,6 +21,7 @@ type RemoteNodeInfo struct {
|
||||
ServerIP string `json:"serverIp"`
|
||||
Status int `json:"status"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
ExpiryTime int64 `json:"expiryTime"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +36,10 @@ type deletePeerShareRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type resetPeerShareFlowRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type nodeImportRequest struct {
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
Token string `json:"token"`
|
||||
@@ -74,6 +79,49 @@ type federationRuntimeDiagnoseRequest struct {
|
||||
Timeout int `json:"timeout"`
|
||||
}
|
||||
|
||||
type peerShareUsedPort struct {
|
||||
RuntimeID int64 `json:"runtimeId"`
|
||||
Port int `json:"port"`
|
||||
Role string `json:"role"`
|
||||
Protocol string `json:"protocol"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
Applied int `json:"applied"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
type peerShareListItem struct {
|
||||
sqlite.PeerShare
|
||||
UsedPorts []int `json:"usedPorts"`
|
||||
UsedPortDetails []peerShareUsedPort `json:"usedPortDetails"`
|
||||
ActiveRuntimeNum int `json:"activeRuntimeNum"`
|
||||
}
|
||||
|
||||
type remoteUsageBindingItem struct {
|
||||
BindingID int64 `json:"bindingId"`
|
||||
TunnelID int64 `json:"tunnelId"`
|
||||
TunnelName string `json:"tunnelName"`
|
||||
ChainType int `json:"chainType"`
|
||||
HopInx int `json:"hopInx"`
|
||||
AllocatedPort int `json:"allocatedPort"`
|
||||
ResourceKey string `json:"resourceKey"`
|
||||
RemoteBindingID string `json:"remoteBindingId"`
|
||||
UpdatedTime int64 `json:"updatedTime"`
|
||||
}
|
||||
|
||||
type remoteUsageNodeItem struct {
|
||||
NodeID int64 `json:"nodeId"`
|
||||
NodeName string `json:"nodeName"`
|
||||
RemoteURL string `json:"remoteUrl"`
|
||||
ShareID int64 `json:"shareId"`
|
||||
PortRangeStart int `json:"portRangeStart"`
|
||||
PortRangeEnd int `json:"portRangeEnd"`
|
||||
MaxBandwidth int64 `json:"maxBandwidth"`
|
||||
CurrentFlow int64 `json:"currentFlow"`
|
||||
UsedPorts []int `json:"usedPorts"`
|
||||
Bindings []remoteUsageBindingItem `json:"bindings"`
|
||||
ActiveBindingNum int `json:"activeBindingNum"`
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
@@ -85,7 +133,55 @@ func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
response.WriteJSON(w, response.OK(shares))
|
||||
|
||||
items := make([]peerShareListItem, 0, len(shares))
|
||||
for i := range shares {
|
||||
share := shares[i]
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(share.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{}, len(runtimes))
|
||||
details := make([]peerShareUsedPort, 0, len(runtimes))
|
||||
for _, runtime := range runtimes {
|
||||
if runtime.Port > 0 {
|
||||
usedSet[runtime.Port] = struct{}{}
|
||||
}
|
||||
details = append(details, peerShareUsedPort{
|
||||
RuntimeID: runtime.ID,
|
||||
Port: runtime.Port,
|
||||
Role: runtime.Role,
|
||||
Protocol: runtime.Protocol,
|
||||
ResourceKey: runtime.ResourceKey,
|
||||
Applied: runtime.Applied,
|
||||
UpdatedTime: runtime.UpdatedTime,
|
||||
})
|
||||
}
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
usedPorts = append(usedPorts, port)
|
||||
}
|
||||
sort.Ints(usedPorts)
|
||||
|
||||
sort.Slice(details, func(i, j int) bool {
|
||||
if details[i].Port == details[j].Port {
|
||||
return details[i].RuntimeID < details[j].RuntimeID
|
||||
}
|
||||
return details[i].Port < details[j].Port
|
||||
})
|
||||
|
||||
items = append(items, peerShareListItem{
|
||||
PeerShare: share,
|
||||
UsedPorts: usedPorts,
|
||||
UsedPortDetails: details,
|
||||
ActiveRuntimeNum: len(details),
|
||||
})
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareCreate(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -191,6 +287,153 @@ func (h *Handler) federationShareDelete(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) federationShareResetFlow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
var req resetPeerShareFlowRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||
return
|
||||
}
|
||||
if req.ID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("Share ID is required"))
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.repo.GetPeerShare(req.ID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if share == nil {
|
||||
response.WriteJSON(w, response.ErrDefault("Share not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.ResetPeerShareCurrentFlow(req.ID, time.Now().UnixMilli()); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OKEmpty())
|
||||
}
|
||||
|
||||
func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := h.repo.DB().Query(`
|
||||
SELECT id, name, remote_url, remote_config
|
||||
FROM node
|
||||
WHERE is_remote = 1
|
||||
ORDER BY id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]remoteUsageNodeItem, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
nodeID int64
|
||||
nodeName string
|
||||
remoteURL sql.NullString
|
||||
remoteConfig sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&nodeID, &nodeName, &remoteURL, &remoteConfig); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
shareID, maxBandwidth, currentFlow, portRangeStart, portRangeEnd := parseRemoteShareUsageConfig(remoteConfig.String)
|
||||
|
||||
bindingRows, err := h.repo.DB().Query(`
|
||||
SELECT fb.id, fb.tunnel_id, COALESCE(t.name, ''), fb.chain_type, fb.hop_inx, fb.allocated_port, fb.resource_key, fb.remote_binding_id, fb.updated_time
|
||||
FROM federation_tunnel_binding fb
|
||||
LEFT JOIN tunnel t ON t.id = fb.tunnel_id
|
||||
WHERE fb.node_id = ? AND fb.status = 1
|
||||
ORDER BY fb.allocated_port ASC, fb.id ASC
|
||||
`, nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{})
|
||||
bindings := make([]remoteUsageBindingItem, 0)
|
||||
for bindingRows.Next() {
|
||||
var item remoteUsageBindingItem
|
||||
if err := bindingRows.Scan(&item.BindingID, &item.TunnelID, &item.TunnelName, &item.ChainType, &item.HopInx, &item.AllocatedPort, &item.ResourceKey, &item.RemoteBindingID, &item.UpdatedTime); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
bindings = append(bindings, item)
|
||||
if item.AllocatedPort > 0 {
|
||||
usedSet[item.AllocatedPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
if err := bindingRows.Err(); err != nil {
|
||||
_ = bindingRows.Close()
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
_ = bindingRows.Close()
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
usedPorts = append(usedPorts, port)
|
||||
}
|
||||
sort.Ints(usedPorts)
|
||||
|
||||
items = append(items, remoteUsageNodeItem{
|
||||
NodeID: nodeID,
|
||||
NodeName: nodeName,
|
||||
RemoteURL: strings.TrimSpace(remoteURL.String),
|
||||
ShareID: shareID,
|
||||
PortRangeStart: portRangeStart,
|
||||
PortRangeEnd: portRangeEnd,
|
||||
MaxBandwidth: maxBandwidth,
|
||||
CurrentFlow: currentFlow,
|
||||
UsedPorts: usedPorts,
|
||||
Bindings: bindings,
|
||||
ActiveBindingNum: len(bindings),
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
}
|
||||
|
||||
func parseRemoteShareUsageConfig(raw string) (int64, int64, int64, int, int) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return 0, 0, 0, 0, 0
|
||||
}
|
||||
|
||||
shareID := asInt64(cfg["shareId"], 0)
|
||||
maxBandwidth := asInt64(cfg["maxBandwidth"], 0)
|
||||
currentFlow := asInt64(cfg["currentFlow"], 0)
|
||||
portRangeStart := int(asInt64(cfg["portRangeStart"], 0))
|
||||
portRangeEnd := int(asInt64(cfg["portRangeEnd"], 0))
|
||||
return shareID, maxBandwidth, currentFlow, portRangeStart, portRangeEnd
|
||||
}
|
||||
|
||||
func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||
@@ -225,6 +468,7 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
||||
configData := map[string]interface{}{
|
||||
"shareId": info.ShareID,
|
||||
"maxBandwidth": info.MaxBandwidth,
|
||||
"currentFlow": info.CurrentFlow,
|
||||
"expiryTime": info.ExpiryTime,
|
||||
"portRangeStart": info.PortRangeStart,
|
||||
"portRangeEnd": info.PortRangeEnd,
|
||||
@@ -370,6 +614,7 @@ func (h *Handler) federationConnect(w http.ResponseWriter, r *http.Request) {
|
||||
"serverIp": serverIP,
|
||||
"status": status,
|
||||
"maxBandwidth": share.MaxBandwidth,
|
||||
"currentFlow": share.CurrentFlow,
|
||||
"expiryTime": share.ExpiryTime,
|
||||
"portRangeStart": share.PortRangeStart,
|
||||
"portRangeEnd": share.PortRangeEnd,
|
||||
@@ -388,6 +633,10 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
var req federationTunnelRequest
|
||||
if err := decodeJSON(r.Body, &req); err != nil {
|
||||
@@ -488,6 +737,10 @@ func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Re
|
||||
}))
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
|
||||
if err != nil {
|
||||
@@ -597,6 +850,10 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
|
||||
}))
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(share) {
|
||||
response.WriteJSON(w, response.Err(403, "Share traffic limit exceeded"))
|
||||
return
|
||||
}
|
||||
|
||||
node, err := h.getNodeRecord(share.NodeID)
|
||||
if err != nil {
|
||||
@@ -885,6 +1142,16 @@ func extractBearerToken(r *http.Request) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func isPeerShareFlowExceeded(share *sqlite.PeerShare) bool {
|
||||
if share == nil {
|
||||
return false
|
||||
}
|
||||
if share.MaxBandwidth <= 0 {
|
||||
return false
|
||||
}
|
||||
return share.CurrentFlow >= share.MaxBandwidth
|
||||
}
|
||||
|
||||
func normalizePeerShareAllowedIPs(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
@@ -146,3 +151,60 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
||||
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "limited-share",
|
||||
NodeID: 1,
|
||||
Token: "limited-token",
|
||||
MaxBandwidth: 2048,
|
||||
CurrentFlow: 2048,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(map[string]interface{}{
|
||||
"resourceKey": "tunnel:1:node:1:type:3:hop:0",
|
||||
"protocol": "tls",
|
||||
"requestedPort": 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/runtime/reserve-port", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer limited-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationRuntimeReservePort(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 403 {
|
||||
t.Fatalf("expected response code 403, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
if payload.Msg != "Share traffic limit exceeded" {
|
||||
t.Fatalf("unexpected response message: %q", payload.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,251 @@ func TestFederationShareCreateRejectsInvalidAllowedIPs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareListIncludesRemoteUsedPorts(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "provider-share",
|
||||
NodeID: 9,
|
||||
Token: "share-list-token",
|
||||
MaxBandwidth: 1024,
|
||||
CurrentFlow: 512,
|
||||
PortRangeStart: 22000,
|
||||
PortRangeEnd: 22010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
|
||||
share, err := repo.GetPeerShareByToken("share-list-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
share.ID, share.NodeID, "r-1", "rk-1", "b-1", "middle", "fed_chain_1", "fed_svc_1", "tls", "round", 22001, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-2", "rk-2", "b-2", "exit", "", "fed_svc_2", "tls", "round", 22002, "", 1, 1, now, now,
|
||||
share.ID, share.NodeID, "r-3", "rk-3", "", "", "", "", "tls", "round", 22003, "", 0, 0, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime rows: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationShareList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty share list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected share row object, got %T", rows[0])
|
||||
}
|
||||
|
||||
if int(first["activeRuntimeNum"].(float64)) != 2 {
|
||||
t.Fatalf("expected activeRuntimeNum=2, got %v", first["activeRuntimeNum"])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 2 {
|
||||
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||
}
|
||||
if int(usedPortsRaw[0].(float64)) != 22001 || int(usedPortsRaw[1].(float64)) != 22002 {
|
||||
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
detailsRaw, ok := first["usedPortDetails"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPortDetails array, got %T", first["usedPortDetails"])
|
||||
}
|
||||
if len(detailsRaw) != 2 {
|
||||
t.Fatalf("expected 2 usedPortDetails rows, got %d", len(detailsRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationShareResetFlow(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "reset-flow-share",
|
||||
NodeID: 11,
|
||||
Token: "reset-flow-token",
|
||||
MaxBandwidth: 4096,
|
||||
CurrentFlow: 2048,
|
||||
PortRangeStart: 23000,
|
||||
PortRangeEnd: 23010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("reset-flow-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(resetPeerShareFlowRequest{ID: share.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/reset-flow", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationShareResetFlow(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
updated, err := repo.GetPeerShare(share.ID)
|
||||
if err != nil || updated == nil {
|
||||
t.Fatalf("reload peer share: %v", err)
|
||||
}
|
||||
if updated.CurrentFlow != 0 {
|
||||
t.Fatalf("expected current flow reset to 0, got %d", updated.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repo.Close() })
|
||||
|
||||
h := New(repo, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
resNode, err := repo.DB().Exec(`
|
||||
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "remote-consumer-node", "remote-consumer-secret", "10.30.40.50", "10.30.40.50", "", "31000-31010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "http://peer.example", "peer-token", `{"shareId":88,"maxBandwidth":2147483648,"currentFlow":1073741824,"portRangeStart":31000,"portRangeEnd":31010}`)
|
||||
if err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
nodeID, err := resNode.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("remote node id: %v", err)
|
||||
}
|
||||
|
||||
resTunnelA, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-a", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel a: %v", err)
|
||||
}
|
||||
tunnelAID, _ := resTunnelA.LastInsertId()
|
||||
|
||||
resTunnelB, err := repo.DB().Exec(`INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`, "consumer-tunnel-b", 2, "tls", 1, now, now, 1, "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("insert tunnel b: %v", err)
|
||||
}
|
||||
tunnelBID, _ := resTunnelB.LastInsertId()
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
tunnelAID, nodeID, 2, 1, "http://peer.example", "rk-a", "rb-a", 31001, 1, now, now,
|
||||
tunnelBID, nodeID, 3, 0, "http://peer.example", "rk-b", "rb-b", 31002, 1, now, now,
|
||||
); err != nil {
|
||||
t.Fatalf("insert federation bindings: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected first usage row map, got %T", rows[0])
|
||||
}
|
||||
if int64(first["shareId"].(float64)) != 88 {
|
||||
t.Fatalf("expected shareId=88, got %v", first["shareId"])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 2 {
|
||||
t.Fatalf("expected 2 used ports, got %d", len(usedPortsRaw))
|
||||
}
|
||||
if int(usedPortsRaw[0].(float64)) != 31001 || int(usedPortsRaw[1].(float64)) != 31002 {
|
||||
t.Fatalf("unexpected used ports payload: %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||
}
|
||||
if len(bindingsRaw) != 2 {
|
||||
t.Fatalf("expected 2 binding rows, got %d", len(bindingsRaw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -38,16 +38,21 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
}
|
||||
|
||||
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
|
||||
if !ok {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
runtimeID, ok := parsePeerShareRuntimeServiceID(serviceName)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
h.processPeerShareFlow(runtimeID, item)
|
||||
}
|
||||
|
||||
func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
@@ -66,6 +71,72 @@ func parseFlowServiceIDs(serviceName string) (int64, int64, int64, bool) {
|
||||
return forwardID, userID, userTunnelID, true
|
||||
}
|
||||
|
||||
func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
const prefix = "fed_svc_"
|
||||
if !strings.HasPrefix(serviceName, prefix) {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(serviceName, prefix)
|
||||
if raw == "" {
|
||||
return 0, false
|
||||
}
|
||||
parts := strings.SplitN(raw, "_", 2)
|
||||
runtimeID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || runtimeID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
}
|
||||
runtime, err := h.repo.GetPeerShareRuntimeByID(runtimeID)
|
||||
if err != nil || runtime == nil || runtime.ShareID <= 0 || runtime.Status != 1 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
share, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
}
|
||||
runtimes, err := h.repo.ListActivePeerShareRuntimesByShareID(shareID)
|
||||
if err != nil || len(runtimes) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, runtime := range runtimes {
|
||||
if h.wsServer != nil && runtime.Applied == 1 {
|
||||
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||
}
|
||||
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||
_, _ = h.sendNodeCommand(runtime.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||
}
|
||||
}
|
||||
_ = h.repo.MarkPeerShareRuntimeReleased(runtime.ID, now)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) scaleFlowByTunnel(forwardID int64, inFlow int64, outFlow int64) (int64, int64) {
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer repo.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := repo.CreatePeerShare(&sqlite.PeerShare{
|
||||
Name: "flow-share",
|
||||
NodeID: 1,
|
||||
Token: "flow-share-token",
|
||||
MaxBandwidth: 3000,
|
||||
CurrentFlow: 1000,
|
||||
PortRangeStart: 32000,
|
||||
PortRangeEnd: 32010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := repo.GetPeerShareByToken("flow-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if _, err := repo.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 17, share.ID, share.NodeID, "res-17", "rk-17", "17", "exit", "", "fed_svc_17", "tls", "round", 32001, "", 1, 1, now, now); err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: repo}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := repo.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 3100 {
|
||||
t.Fatalf("expected current_flow=3100, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
|
||||
runtime, err := repo.GetPeerShareRuntimeByID(17)
|
||||
if err != nil || runtime == nil {
|
||||
t.Fatalf("reload runtime: %v", err)
|
||||
}
|
||||
if runtime.Status != 0 {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,8 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/federation/share/list", h.federationShareList)
|
||||
mux.HandleFunc("/api/v1/federation/share/create", h.federationShareCreate)
|
||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||
mux.HandleFunc("/api/v1/federation/share/reset-flow", h.federationShareResetFlow)
|
||||
mux.HandleFunc("/api/v1/federation/share/remote-usage/list", h.federationRemoteUsageList)
|
||||
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
||||
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
||||
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
|
||||
|
||||
@@ -1450,6 +1450,80 @@ func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID str
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetPeerShareRuntimeByID(id int64) (*PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
row := r.db.QueryRow(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`, id)
|
||||
var item PeerShareRuntime
|
||||
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActivePeerShareRuntimesByShareID(shareID int64) ([]PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
rows, err := r.db.Query(`
|
||||
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||
FROM peer_share_runtime
|
||||
WHERE share_id = ? AND status = 1
|
||||
ORDER BY port ASC, id ASC
|
||||
`, shareID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]PeerShareRuntime, 0)
|
||||
for rows.Next() {
|
||||
var item PeerShareRuntime
|
||||
if err := rows.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repository) AddPeerShareCurrentFlow(shareID int64, delta int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 || delta <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = current_flow + ?, updated_time = ? WHERE id = ?`, delta, unixMilliNow(), shareID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ResetPeerShareCurrentFlow(shareID int64, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
_, err := r.db.Exec(`UPDATE peer_share SET current_flow = 0, updated_time = ? WHERE id = ?`, updatedTime, shareID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) CreatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
|
||||
@@ -202,6 +202,10 @@ export const createPeerShare = (data: {
|
||||
}) => Network.post("/federation/share/create", data);
|
||||
export const deletePeerShare = (id: number) =>
|
||||
Network.post("/federation/share/delete", { id });
|
||||
export const resetPeerShareFlow = (id: number) =>
|
||||
Network.post("/federation/share/reset-flow", { id });
|
||||
export const getPeerRemoteUsageList = () =>
|
||||
Network.post("/federation/share/remote-usage/list");
|
||||
export const importRemoteNode = (data: {
|
||||
remoteUrl: string;
|
||||
token: string;
|
||||
|
||||
+175
-161
@@ -1114,9 +1114,12 @@ export default function NodePage() {
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{sortedNodes.map((node) => (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
{sortedNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
|
||||
return (
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
key={node.id}
|
||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200"
|
||||
@@ -1135,16 +1138,6 @@ export default function NodePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-2">
|
||||
{node.isRemote === 1 && (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing p-2 text-default-400 hover:text-default-600 transition-colors touch-manipulation opacity-100 sm:opacity-0 sm:group-hover:opacity-100"
|
||||
{...listeners}
|
||||
@@ -1159,6 +1152,16 @@ export default function NodePage() {
|
||||
<path d="M7 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 2zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 7 14zm6-8a2 2 0 1 1-.001-4.001A2 2 0 0 1 13 6zm0 2a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 8zm0 6a2 2 0 1 1 .001 4.001A2 2 0 0 1 13 14z" />
|
||||
</svg>
|
||||
</div>
|
||||
{isRemoteNode && (
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
远程
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
className="text-xs"
|
||||
color={
|
||||
@@ -1215,164 +1218,174 @@ export default function NodePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">开机时间</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">版本</span>
|
||||
<span className="text-xs">
|
||||
{node.version || "未知"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">开机时间</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
{/* 系统监控 */}
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="CPU使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="内存使用率"
|
||||
color={getProgressColor(
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0,
|
||||
node.connectionStatus !== "online",
|
||||
)}
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
上传
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.uploadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-600 mb-0.5">
|
||||
下载
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatSpeed(node.systemInfo.downloadSpeed)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 流量统计 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="text-center p-2 bg-primary-50 dark:bg-primary-100/20 rounded border border-primary-200 dark:border-primary-300/20">
|
||||
<div className="text-primary-600 dark:text-primary-400 mb-0.5">
|
||||
↑ 上行流量
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(node.systemInfo.uploadTraffic)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-success-50 dark:bg-success-100/20 rounded border border-success-200 dark:border-success-300/20">
|
||||
<div className="text-success-600 dark:text-success-400 mb-0.5">
|
||||
↓ 下行流量
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex gap-1.5">
|
||||
{!isRemoteNode && (
|
||||
<>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="success"
|
||||
isDisabled={node.isRemote === 1}
|
||||
isLoading={node.copyLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleCopyInstallCommand(node)}
|
||||
>
|
||||
安装
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
color="primary"
|
||||
isDisabled={node.isRemote === 1}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(node)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 min-h-8"
|
||||
className={`min-h-8 ${isRemoteNode ? "w-full" : "flex-1"}`}
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
@@ -1386,7 +1399,8 @@ export default function NodePage() {
|
||||
</Card>
|
||||
)}
|
||||
</SortableItem>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
createPeerShare,
|
||||
getPeerShareList,
|
||||
deletePeerShare,
|
||||
resetPeerShareFlow,
|
||||
getPeerRemoteUsageList,
|
||||
importRemoteNode,
|
||||
} from "@/api";
|
||||
|
||||
@@ -31,19 +33,61 @@ interface PeerShare {
|
||||
name: string;
|
||||
token: string;
|
||||
maxBandwidth: number;
|
||||
currentFlow: number;
|
||||
expiryTime: number;
|
||||
portRangeStart: number;
|
||||
portRangeEnd: number;
|
||||
isActive: number;
|
||||
allowedDomains?: string;
|
||||
allowedIps?: string;
|
||||
usedPorts?: number[];
|
||||
usedPortDetails?: Array<{
|
||||
runtimeId: number;
|
||||
port: number;
|
||||
role: string;
|
||||
protocol: string;
|
||||
resourceKey: string;
|
||||
applied: number;
|
||||
updatedTime: number;
|
||||
}>;
|
||||
activeRuntimeNum?: number;
|
||||
}
|
||||
|
||||
interface RemoteUsageBinding {
|
||||
bindingId: number;
|
||||
tunnelId: number;
|
||||
tunnelName: string;
|
||||
chainType: number;
|
||||
hopInx: number;
|
||||
allocatedPort: number;
|
||||
resourceKey: string;
|
||||
remoteBindingId: string;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
interface RemoteUsageNode {
|
||||
nodeId: number;
|
||||
nodeName: string;
|
||||
remoteUrl: string;
|
||||
shareId: number;
|
||||
portRangeStart: number;
|
||||
portRangeEnd: number;
|
||||
maxBandwidth: number;
|
||||
currentFlow: number;
|
||||
usedPorts: number[];
|
||||
bindings: RemoteUsageBinding[];
|
||||
activeBindingNum: number;
|
||||
}
|
||||
|
||||
export default function PanelSharingPage() {
|
||||
const [selectedTab, setSelectedTab] = useState("my-shares");
|
||||
const [shares, setShares] = useState<PeerShare[]>([]);
|
||||
const [remoteUsageNodes, setRemoteUsageNodes] = useState<RemoteUsageNode[]>(
|
||||
[],
|
||||
);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
|
||||
|
||||
// Modals
|
||||
const [createShareOpen, setCreateShareOpen] = useState(false);
|
||||
@@ -103,12 +147,30 @@ export default function PanelSharingPage() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadRemoteUsage = useCallback(async () => {
|
||||
setRemoteUsageLoading(true);
|
||||
try {
|
||||
const res = await getPeerRemoteUsageList();
|
||||
if (res.code === 0) {
|
||||
setRemoteUsageNodes(res.data || []);
|
||||
} else {
|
||||
toast.error(res.msg || "加载远程占用端口失败");
|
||||
}
|
||||
} finally {
|
||||
setRemoteUsageLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTab === "my-shares") {
|
||||
loadShares();
|
||||
loadNodes();
|
||||
return;
|
||||
}
|
||||
}, [selectedTab, loadShares, loadNodes]);
|
||||
if (selectedTab === "remote-nodes") {
|
||||
loadRemoteUsage();
|
||||
}
|
||||
}, [selectedTab, loadShares, loadNodes, loadRemoteUsage]);
|
||||
|
||||
const handleCreateShare = async () => {
|
||||
if (!shareForm.name || !shareForm.nodeId) {
|
||||
@@ -120,13 +182,17 @@ export default function PanelSharingPage() {
|
||||
toast.error("仅可选择本地节点");
|
||||
return;
|
||||
}
|
||||
if (shareForm.maxBandwidth < 0) {
|
||||
toast.error("流量上限不能为负数");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const expiryTime =
|
||||
Date.now() + shareForm.expiryDays * 24 * 60 * 60 * 1000;
|
||||
const res = await createPeerShare({
|
||||
name: shareForm.name,
|
||||
nodeId,
|
||||
maxBandwidth: shareForm.maxBandwidth * 1024 * 1024 * 1024,
|
||||
maxBandwidth: Math.max(0, shareForm.maxBandwidth) * 1024 * 1024 * 1024,
|
||||
expiryTime: shareForm.expiryDays === 0 ? 0 : expiryTime,
|
||||
portRangeStart: shareForm.portRangeStart,
|
||||
portRangeEnd: shareForm.portRangeEnd,
|
||||
@@ -159,6 +225,20 @@ export default function PanelSharingPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShareFlow = async (id: number) => {
|
||||
try {
|
||||
const res = await resetPeerShareFlow(id);
|
||||
if (res.code === 0) {
|
||||
toast.success("共享流量已重置");
|
||||
loadShares();
|
||||
} else {
|
||||
toast.error(res.msg || "重置流量失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportNode = async () => {
|
||||
if (!importForm.remoteUrl || !importForm.token) {
|
||||
toast.error("请填写完整信息");
|
||||
@@ -179,6 +259,7 @@ export default function PanelSharingPage() {
|
||||
toast.success("导入成功,请前往节点列表查看");
|
||||
setImportNodeOpen(false);
|
||||
setImportForm({ remoteUrl: "", token: "" });
|
||||
loadRemoteUsage();
|
||||
} else {
|
||||
toast.error(res.msg || "导入失败");
|
||||
}
|
||||
@@ -192,6 +273,23 @@ export default function PanelSharingPage() {
|
||||
toast.success("Token已复制");
|
||||
};
|
||||
|
||||
const formatFlowGB = (bytes: number) => {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "0.00 GB";
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const formatChainType = (chainType: number, hopInx: number) => {
|
||||
if (chainType === 2) {
|
||||
return `中继跳点 #${hopInx}`;
|
||||
}
|
||||
if (chainType === 3) {
|
||||
return "出口节点";
|
||||
}
|
||||
return "未知链路";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
@@ -222,10 +320,31 @@ export default function PanelSharingPage() {
|
||||
<Card key={share.id} className="border border-divider shadow-sm">
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{share.name}</h3>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleResetShareFlow(share.id)}
|
||||
>
|
||||
重置流量
|
||||
</Button>
|
||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
||||
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
||||
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
||||
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{share.usedPortDetails.map((item) => (
|
||||
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
||||
{item.port} / {item.role || "reserved"}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{share.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
||||
@@ -249,10 +368,46 @@ export default function PanelSharingPage() {
|
||||
导入远程节点
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>已导入的节点将显示在“节点管理”页面,带有“远程”标记。</p>
|
||||
<p className="mt-2">请使用其创建隧道。</p>
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<p>暂无远程节点占用记录。</p>
|
||||
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{remoteUsageNodes.map((node) => (
|
||||
<Card key={node.nodeId} className="border border-divider shadow-sm">
|
||||
<CardHeader className="flex justify-between">
|
||||
<h3 className="font-bold">{node.nodeName}</h3>
|
||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
||||
</CardHeader>
|
||||
<CardBody className="text-sm space-y-2">
|
||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||
<p>共享ID: {node.shareId || "-"}</p>
|
||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
||||
<p>共享流量: {node.maxBandwidth > 0 ? `${formatFlowGB(node.currentFlow || 0)} / ${formatFlowGB(node.maxBandwidth)}` : `${formatFlowGB(node.currentFlow || 0)} / 不限制`}</p>
|
||||
<p>远端占用端口: {node.usedPorts && node.usedPorts.length > 0 ? node.usedPorts.join(", ") : "暂无"}</p>
|
||||
{node.bindings && node.bindings.length > 0 && (
|
||||
<div className="space-y-1 pt-1">
|
||||
{node.bindings.map((binding) => (
|
||||
<p key={binding.bindingId} className="text-xs text-default-600">
|
||||
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
||||
{" · "}
|
||||
端口 {binding.allocatedPort}
|
||||
{" · "}
|
||||
{formatChainType(binding.chainType, binding.hopInx)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Tab>
|
||||
@@ -302,6 +457,13 @@ export default function PanelSharingPage() {
|
||||
value={shareForm.expiryDays.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
||||
/>
|
||||
<Input
|
||||
label="流量上限 (GB)"
|
||||
type="number"
|
||||
description="0 表示不限流量"
|
||||
value={shareForm.maxBandwidth.toString()}
|
||||
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="允许的域名 (可选)"
|
||||
placeholder="example.com, panel.test.com"
|
||||
|
||||
Reference in New Issue
Block a user