mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 01:56:37 +08:00
Merge branch 'main' into opencode/kind-planet
This commit is contained in:
@@ -87,4 +87,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.80.0.0/16
|
||||||
|
|||||||
@@ -88,5 +88,5 @@ networks:
|
|||||||
enable_ipv6: true
|
enable_ipv6: true
|
||||||
ipam:
|
ipam:
|
||||||
config:
|
config:
|
||||||
- subnet: 172.20.0.0/16
|
- subnet: 172.80.0.0/16
|
||||||
- subnet: fd00:dead:beef::/48
|
- subnet: fd00:dead:beef::/48
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ type RuntimeDiagnoseRequest struct {
|
|||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeNodeCommandRequest struct {
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeNodeCommandResponse struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewFederationClient() *FederationClient {
|
func NewFederationClient() *FederationClient {
|
||||||
return &FederationClient{
|
return &FederationClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -333,3 +345,42 @@ func (c *FederationClient) Diagnose(url, token, localDomain string, reqData Runt
|
|||||||
|
|
||||||
return res.Data, nil
|
return res.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *FederationClient) Command(url, token, localDomain string, reqData RuntimeNodeCommandRequest) (*RuntimeNodeCommandResponse, error) {
|
||||||
|
url = strings.TrimSuffix(url, "/")
|
||||||
|
bodyBytes, _ := json.Marshal(reqData)
|
||||||
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/command", strings.NewReader(string(bodyBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if localDomain != "" {
|
||||||
|
req.Header.Set("X-Panel-Domain", localDomain)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data RuntimeNodeCommandResponse `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &res.Data, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -457,7 +457,17 @@ func (h *Handler) applyNodeProtocolChange(nodeID int64, httpVal, tlsVal, socksVa
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interface{}, tolerateExists bool, tolerateNotFound bool) (ws.CommandResult, error) {
|
||||||
result, err := h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
var (
|
||||||
|
result ws.CommandResult
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
node, nodeErr := h.getNodeRecord(nodeID)
|
||||||
|
if nodeErr == nil && node != nil && node.IsRemote == 1 {
|
||||||
|
result, err = h.sendRemoteNodeCommand(node, commandType, data)
|
||||||
|
} else {
|
||||||
|
result, err = h.wsServer.SendCommand(nodeID, commandType, data, 12*time.Second)
|
||||||
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -475,6 +485,44 @@ func (h *Handler) sendNodeCommand(nodeID int64, commandType string, data interfa
|
|||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendRemoteNodeCommand(node *nodeRecord, commandType string, data interface{}) (ws.CommandResult, error) {
|
||||||
|
if node == nil {
|
||||||
|
return ws.CommandResult{}, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||||
|
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||||
|
if remoteURL == "" || remoteToken == "" {
|
||||||
|
return ws.CommandResult{}, errors.New("远程节点缺少共享配置")
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := client.NewFederationClient()
|
||||||
|
res, err := fc.Command(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeNodeCommandRequest{
|
||||||
|
CommandType: commandType,
|
||||||
|
Data: data,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ws.CommandResult{}, err
|
||||||
|
}
|
||||||
|
if res == nil {
|
||||||
|
return ws.CommandResult{}, errors.New("远程节点未返回命令结果")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ws.CommandResult{
|
||||||
|
Type: res.Type,
|
||||||
|
Success: res.Success,
|
||||||
|
Message: res.Message,
|
||||||
|
Data: res.Data,
|
||||||
|
}
|
||||||
|
if !result.Success {
|
||||||
|
msg := strings.TrimSpace(result.Message)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "命令执行失败"
|
||||||
|
}
|
||||||
|
return result, errors.New(msg)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
func (h *Handler) diagnoseForwardRuntime(forward *forwardRecord) (map[string]interface{}, error) {
|
||||||
if forward == nil {
|
if forward == nil {
|
||||||
return nil, errForwardNotFound
|
return nil, errForwardNotFound
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ type federationRuntimeDiagnoseRequest struct {
|
|||||||
Timeout int `json:"timeout"`
|
Timeout int `json:"timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type federationRuntimeCommandRequest struct {
|
||||||
|
CommandType string `json:"commandType"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type peerShareUsedPort struct {
|
type peerShareUsedPort struct {
|
||||||
RuntimeID int64 `json:"runtimeId"`
|
RuntimeID int64 `json:"runtimeId"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
@@ -1199,6 +1204,51 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
|
|||||||
response.WriteJSON(w, response.OK(res.Data))
|
response.WriteJSON(w, response.OK(res.Data))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
share, err := h.repo.GetPeerShareByToken(token)
|
||||||
|
if err != nil || share == nil {
|
||||||
|
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req federationRuntimeCommandRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmd := strings.TrimSpace(req.CommandType)
|
||||||
|
if cmd == "" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("commandType is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !isFederationRuntimeCommandAllowed(cmd) {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("command not allowed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := h.sendNodeCommand(share.NodeID, cmd, req.Data, false, false)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OK(res))
|
||||||
|
}
|
||||||
|
|
||||||
|
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||||
|
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||||
if share == nil {
|
if share == nil {
|
||||||
return 0, fmt.Errorf("share not found")
|
return 0, fmt.Errorf("share not found")
|
||||||
|
|||||||
@@ -152,6 +152,71 @@ func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrepareTunnelCreateStateAllowsOfflineRemoteMiddleNode(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()
|
||||||
|
|
||||||
|
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||||
|
res, execErr := 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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":2}`)
|
||||||
|
if execErr != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, execErr)
|
||||||
|
}
|
||||||
|
id, idErr := res.LastInsertId()
|
||||||
|
if idErr != nil {
|
||||||
|
t.Fatalf("node id %s: %v", name, idErr)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
entryID := insertNode("entry-local", 1, "32000-32010", 0)
|
||||||
|
remoteMiddleID := insertNode("middle-remote", 0, "33000-33010", 1)
|
||||||
|
outID := insertNode("out-local", 1, "34000-34010", 0)
|
||||||
|
|
||||||
|
tx, err := repo.DB().Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin tx: %v", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"name": "remote-middle-offline-status",
|
||||||
|
"inNodeId": []interface{}{
|
||||||
|
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
"chainNodes": []interface{}{
|
||||||
|
[]interface{}{
|
||||||
|
map[string]interface{}{"nodeId": float64(remoteMiddleID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"outNodeId": []interface{}{
|
||||||
|
map[string]interface{}{"nodeId": float64(outID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare state should allow offline remote middle node: %v", err)
|
||||||
|
}
|
||||||
|
if len(state.ChainHops) != 1 || len(state.ChainHops[0]) != 1 {
|
||||||
|
t.Fatalf("expected one middle hop node, got %+v", state.ChainHops)
|
||||||
|
}
|
||||||
|
if state.ChainHops[0][0].NodeID != remoteMiddleID {
|
||||||
|
t.Fatalf("expected remote middle node id %d, got %d", remoteMiddleID, state.ChainHops[0][0].NodeID)
|
||||||
|
}
|
||||||
|
if state.Nodes[remoteMiddleID] == nil || state.Nodes[remoteMiddleID].IsRemote != 1 {
|
||||||
|
t.Fatalf("expected remote middle node metadata in state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
func TestFederationRuntimeReservePortRejectsWhenShareFlowExceeded(t *testing.T) {
|
||||||
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -93,6 +93,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||||
|
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
|
||||||
|
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
|
||||||
|
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
|
||||||
|
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
|
||||||
|
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
|
||||||
|
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
|
||||||
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
||||||
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||||
@@ -169,6 +175,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||||
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||||
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose))
|
||||||
|
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
|
||||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||||
|
|
||||||
mux.HandleFunc("/flow/test", h.flowTest)
|
mux.HandleFunc("/flow/test", h.flowTest)
|
||||||
@@ -1140,3 +1147,84 @@ func (h *Handler) verifyCloudflareTurnstile(token, secretKey string) bool {
|
|||||||
}
|
}
|
||||||
return body.Success
|
return body.Success
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type backupExportRequest struct {
|
||||||
|
Types []string `json:"types"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) backupExport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backupExportRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var backup interface{}
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if len(req.Types) == 0 {
|
||||||
|
backup, err = h.repo.ExportAll()
|
||||||
|
} else {
|
||||||
|
backup, err = h.repo.ExportPartial(req.Types)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Disposition", "attachment; filename=backup.json")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(backup); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupImportRequest struct {
|
||||||
|
Types []string `json:"types"`
|
||||||
|
sqlite.BackupData
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req backupImportRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请求参数错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Types) == 0 {
|
||||||
|
response.WriteJSON(w, response.Err(500, "请选择要导入的数据类型"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
autoBackup, err := h.repo.ExportAll()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入前自动备份失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.BackupData.Version == "" {
|
||||||
|
response.WriteJSON(w, response.Err(500, "备份数据格式错误"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.repo.Import(&req.BackupData, req.Types)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("导入失败: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result.AutoBackup = autoBackup
|
||||||
|
response.WriteJSON(w, response.OK(result))
|
||||||
|
}
|
||||||
|
|||||||
@@ -1713,10 +1713,19 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
previousUserIDs, err := queryInt64ListTx(tx, `SELECT user_id FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
_, _ = tx.Exec(`DELETE FROM user_group_user WHERE user_group_id = ?`, req.GroupID)
|
||||||
for _, uid := range req.UserIDs {
|
for _, uid := range req.UserIDs {
|
||||||
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
_, _ = tx.Exec(`INSERT INTO user_group_user(user_group_id, user_id, created_time) VALUES(?, ?, ?) ON CONFLICT DO NOTHING`, req.GroupID, uid, time.Now().UnixMilli())
|
||||||
}
|
}
|
||||||
|
if err := revokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -1748,10 +1757,35 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tx, err := h.repo.DB().Begin()
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
var ug, tg int64
|
var ug, tg int64
|
||||||
_ = h.repo.DB().QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
err = tx.QueryRow(`SELECT user_group_id, tunnel_group_id FROM group_permission WHERE id = ?`, id).Scan(&ug, &tg)
|
||||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission WHERE id = ?`, id)
|
if err != nil && err != sql.ErrNoRows {
|
||||||
_, _ = h.repo.DB().Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, ug, tg)
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM group_permission WHERE id = ?`, id); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if err := revokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1908,6 +1942,144 @@ func queryInt64List(db *store.DB, q string, args ...interface{}) ([]int64, error
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryInt64ListTx(tx *store.Tx, q string, args ...interface{}) ([]int64, error) {
|
||||||
|
rows, err := tx.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]int64, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeGroupGrantsForRemovedUsersTx(tx *store.Tx, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
||||||
|
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||||
|
for _, uid := range currentUserIDs {
|
||||||
|
if uid > 0 {
|
||||||
|
currentSet[uid] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removedUserIDs := make([]int64, 0)
|
||||||
|
for _, uid := range previousUserIDs {
|
||||||
|
if uid <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := currentSet[uid]; !ok {
|
||||||
|
removedUserIDs = append(removedUserIDs, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(removedUserIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, userID := range removedUserIDs {
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT g.user_tunnel_id, g.created_by_group
|
||||||
|
FROM group_permission_grant g
|
||||||
|
JOIN user_tunnel ut ON ut.id = g.user_tunnel_id
|
||||||
|
WHERE g.user_group_id = ? AND ut.user_id = ?
|
||||||
|
`, userGroupID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var userTunnelID int64
|
||||||
|
var createdByGroup int
|
||||||
|
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdByGroup == 1 && userTunnelID > 0 {
|
||||||
|
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
DELETE FROM group_permission_grant
|
||||||
|
WHERE user_group_id = ?
|
||||||
|
AND user_tunnel_id IN (SELECT id FROM user_tunnel WHERE user_id = ?)
|
||||||
|
`, userGroupID, userID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
|
var remaining int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func revokeGroupPermissionPairTx(tx *store.Tx, userGroupID, tunnelGroupID int64) error {
|
||||||
|
rows, err := tx.Query(`
|
||||||
|
SELECT user_tunnel_id, created_by_group
|
||||||
|
FROM group_permission_grant
|
||||||
|
WHERE user_group_id = ? AND tunnel_group_id = ?
|
||||||
|
`, userGroupID, tunnelGroupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
|
for rows.Next() {
|
||||||
|
var userTunnelID int64
|
||||||
|
var createdByGroup int
|
||||||
|
if err := rows.Scan(&userTunnelID, &createdByGroup); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if createdByGroup == 1 && userTunnelID > 0 {
|
||||||
|
groupCreatedTunnelIDs[userTunnelID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
|
var remaining int
|
||||||
|
if err := tx.QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&remaining); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if remaining == 0 {
|
||||||
|
if _, err := tx.Exec(`DELETE FROM user_tunnel WHERE id = ?`, userTunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
func queryPairs(db *store.DB, q string, args ...interface{}) ([][2]int64, error) {
|
||||||
rows, err := db.Query(q, args...)
|
rows, err := db.Query(q, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2061,7 +2233,7 @@ func (h *Handler) prepareTunnelCreateState(tx *store.Tx, req map[string]interfac
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if node.Status != 1 {
|
if node.IsRemote != 1 && node.Status != 1 {
|
||||||
return nil, errors.New("部分节点不在线")
|
return nil, errors.New("部分节点不在线")
|
||||||
}
|
}
|
||||||
state.Nodes[nodeID] = node
|
state.Nodes[nodeID] = node
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ func shouldSkip(path string) bool {
|
|||||||
return true
|
return true
|
||||||
case path == "/api/v1/federation/runtime/diagnose":
|
case path == "/api/v1/federation/runtime/diagnose":
|
||||||
return true
|
return true
|
||||||
|
case path == "/api/v1/federation/runtime/command":
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -115,6 +117,14 @@ func requiresAdmin(path string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api/v1/backup/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(path, "/api/v1/tunnel/") {
|
if strings.HasPrefix(path, "/api/v1/tunnel/") {
|
||||||
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
|
||||||
return false
|
return false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,8 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
|||||||
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||||
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||||
|
|
||||||
|
stopEntry := startMockNodeSession(t, providerServer.URL, "provider-entry-secret")
|
||||||
|
defer stopEntry()
|
||||||
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||||
defer stopMiddle()
|
defer stopMiddle()
|
||||||
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-secret")
|
||||||
@@ -148,6 +150,23 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
|||||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||||
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||||
|
|
||||||
|
forwardPayload := map[string]interface{}{
|
||||||
|
"name": "dual-panel-remote-entry-forward",
|
||||||
|
"tunnelId": secondTunnelID,
|
||||||
|
"remoteAddr": "1.1.1.1:443",
|
||||||
|
"strategy": "fifo",
|
||||||
|
}
|
||||||
|
forwardBody, err := json.Marshal(forwardPayload)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal forward payload: %v", err)
|
||||||
|
}
|
||||||
|
forwardReq := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(forwardBody))
|
||||||
|
forwardReq.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
forwardReq.Header.Set("Content-Type", "application/json")
|
||||||
|
forwardRes := httptest.NewRecorder()
|
||||||
|
consumerRouter.ServeHTTP(forwardRes, forwardReq)
|
||||||
|
assertCode(t, forwardRes, 0)
|
||||||
|
|
||||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGroupUserUnbindRevokesInheritedTunnelPermission(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(200, 'group_user_contract', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now); err != nil {
|
||||||
|
t.Fatalf("insert test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelRes, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES('group-contract-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||||
|
`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID, err := tunnelRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert user_group: %v", err)
|
||||||
|
}
|
||||||
|
userGroupID, err := ugRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group: %v", err)
|
||||||
|
}
|
||||||
|
tunnelGroupID, err := tgRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO tunnel_group_tunnel(tunnel_group_id, tunnel_id, created_time) VALUES(?, ?, ?)`, tunnelGroupID, tunnelID, now); err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO group_permission(user_group_id, tunnel_group_id, created_time) VALUES(?, ?, ?)`, userGroupID, tunnelGroupID, now); err != nil {
|
||||||
|
t.Fatalf("insert group_permission: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[200]}`))
|
||||||
|
bindReq.Header.Set("Authorization", adminToken)
|
||||||
|
bindRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(bindRes, bindReq)
|
||||||
|
assertCode(t, bindRes, 0)
|
||||||
|
|
||||||
|
var userTunnelID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 200 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after bind: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after bind: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount == 0 {
|
||||||
|
t.Fatalf("expected non-zero grants after bind")
|
||||||
|
}
|
||||||
|
|
||||||
|
unbindReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[]}`))
|
||||||
|
unbindReq.Header.Set("Authorization", adminToken)
|
||||||
|
unbindRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(unbindRes, unbindReq)
|
||||||
|
assertCode(t, unbindRes, 0)
|
||||||
|
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after unbind: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount != 0 {
|
||||||
|
t.Fatalf("expected grants revoked after unbind, got %d", grantCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after unbind: %v", err)
|
||||||
|
}
|
||||||
|
if userTunnelCount != 0 {
|
||||||
|
t.Fatalf("expected user_tunnel revoked after unbind, got %d", userTunnelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroupPermissionRemoveRevokesInheritedTunnelPermission(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(201, 'group_user_permission_remove', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now); err != nil {
|
||||||
|
t.Fatalf("insert test user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tunnelRes, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES('group-remove-tunnel', 1.0, 1, 'tls', 99999, ?, ?, 1, NULL, 0)
|
||||||
|
`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID, err := tunnelRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ugRes, err := repo.DB().Exec(`INSERT INTO user_group(name, created_time, updated_time, status) VALUES('ug-remove-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert user_group: %v", err)
|
||||||
|
}
|
||||||
|
userGroupID, err := ugRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tgRes, err := repo.DB().Exec(`INSERT INTO tunnel_group(name, created_time, updated_time, status) VALUES('tg-remove-contract', ?, ?, 1)`, now, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert tunnel_group: %v", err)
|
||||||
|
}
|
||||||
|
tunnelGroupID, err := tgRes.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read tunnel_group id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignTunnelReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/tunnel/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(tunnelGroupID)+`,"tunnelIds":[`+jsonNumber(tunnelID)+`]}`))
|
||||||
|
assignTunnelReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignTunnelRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignTunnelRes, assignTunnelReq)
|
||||||
|
assertCode(t, assignTunnelRes, 0)
|
||||||
|
|
||||||
|
assignUserReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/user/assign", bytes.NewBufferString(`{"groupId":`+jsonNumber(userGroupID)+`,"userIds":[201]}`))
|
||||||
|
assignUserReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignUserRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignUserRes, assignUserReq)
|
||||||
|
assertCode(t, assignUserRes, 0)
|
||||||
|
|
||||||
|
assignPermissionReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/assign", bytes.NewBufferString(`{"userGroupId":`+jsonNumber(userGroupID)+`,"tunnelGroupId":`+jsonNumber(tunnelGroupID)+`}`))
|
||||||
|
assignPermissionReq.Header.Set("Authorization", adminToken)
|
||||||
|
assignPermissionRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(assignPermissionRes, assignPermissionReq)
|
||||||
|
assertCode(t, assignPermissionRes, 0)
|
||||||
|
|
||||||
|
var permissionID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM group_permission WHERE user_group_id = ? AND tunnel_group_id = ?`, userGroupID, tunnelGroupID).Scan(&permissionID); err != nil {
|
||||||
|
t.Fatalf("query group_permission id: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelID int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM user_tunnel WHERE user_id = 201 AND tunnel_id = ?`, tunnelID).Scan(&userTunnelID); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after assign: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var grantCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after assign: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount == 0 {
|
||||||
|
t.Fatalf("expected non-zero grants after permission assign")
|
||||||
|
}
|
||||||
|
|
||||||
|
removeReq := httptest.NewRequest(http.MethodPost, "/api/v1/group/permission/remove", bytes.NewBufferString(`{"id":`+jsonNumber(permissionID)+`}`))
|
||||||
|
removeReq.Header.Set("Authorization", adminToken)
|
||||||
|
removeRes := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(removeRes, removeReq)
|
||||||
|
assertCode(t, removeRes, 0)
|
||||||
|
|
||||||
|
var permissionCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission WHERE id = ?`, permissionID).Scan(&permissionCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission after remove: %v", err)
|
||||||
|
}
|
||||||
|
if permissionCount != 0 {
|
||||||
|
t.Fatalf("expected group_permission removed, got %d", permissionCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM group_permission_grant WHERE user_tunnel_id = ?`, userTunnelID).Scan(&grantCount); err != nil {
|
||||||
|
t.Fatalf("query group_permission_grant after remove: %v", err)
|
||||||
|
}
|
||||||
|
if grantCount != 0 {
|
||||||
|
t.Fatalf("expected grants removed after permission remove, got %d", grantCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
var userTunnelCount int
|
||||||
|
if err := repo.DB().QueryRow(`SELECT COUNT(1) FROM user_tunnel WHERE id = ?`, userTunnelID).Scan(&userTunnelCount); err != nil {
|
||||||
|
t.Fatalf("query user_tunnel after permission remove: %v", err)
|
||||||
|
}
|
||||||
|
if userTunnelCount != 0 {
|
||||||
|
t.Fatalf("expected user_tunnel revoked after permission remove, got %d", userTunnelCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,6 +203,160 @@ func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBackupExportImportRestoreContracts(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate user token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
key := "backup_contract_key"
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO vite_config(name, value, time)
|
||||||
|
VALUES(?, ?, ?)
|
||||||
|
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
|
||||||
|
`, key, "v1", time.Now().UnixMilli()); err != nil {
|
||||||
|
t.Fatalf("seed config for backup contract: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
|
||||||
|
req.Header.Set("Authorization", userToken)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
|
||||||
|
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||||
|
if len(payloadA.Configs) == 0 {
|
||||||
|
t.Fatalf("expected exported configs, got none")
|
||||||
|
}
|
||||||
|
if _, ok := payloadA.Configs[key]; !ok {
|
||||||
|
t.Fatalf("expected %q in exported configs", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
|
||||||
|
if len(payloadB.Configs) == 0 {
|
||||||
|
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("backup import applies exported data", func(t *testing.T) {
|
||||||
|
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||||
|
payload.Configs[key] = "v2"
|
||||||
|
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal import payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode import response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := repo.GetConfigByName(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query imported config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.Value != "v2" {
|
||||||
|
t.Fatalf("expected imported config value v2, got %+v", cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("backup restore alias applies exported data", func(t *testing.T) {
|
||||||
|
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
|
||||||
|
payload.Configs[key] = "v3"
|
||||||
|
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal restore payload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode restore response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := repo.GetConfigByName(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query restored config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg == nil || cfg.Value != "v3" {
|
||||||
|
t.Fatalf("expected restored config value v3, got %+v", cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupExportPayload struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
ExportedAt int64 `json:"exportedAt"`
|
||||||
|
Configs map[string]string `json:"configs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type backupImportPayload struct {
|
||||||
|
Types []string `json:"types"`
|
||||||
|
backupExportPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read backup payload from %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload backupExportPayload
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
t.Fatalf("decode backup payload from %s: %v", path, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(payload.Version) == "" {
|
||||||
|
var out response.R
|
||||||
|
if err := json.Unmarshal(body, &out); err == nil {
|
||||||
|
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
|
||||||
|
}
|
||||||
|
if payload.Configs == nil {
|
||||||
|
t.Fatalf("expected configs map in backup payload on %s", path)
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
dbPath := filepath.Join(t.TempDir(), "contract.db")
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
"@heroui/system": "2.4.19",
|
"@heroui/system": "2.4.19",
|
||||||
"@heroui/table": "^2.2.24",
|
"@heroui/table": "^2.2.24",
|
||||||
"@heroui/tabs": "^2.2.27",
|
"@heroui/tabs": "^2.2.27",
|
||||||
"@heroui/theme": "2.4.19",
|
"@heroui/theme": "2.4.24",
|
||||||
"@heroui/use-theme": "2.1.10",
|
"@heroui/use-theme": "2.1.10",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@nextui-org/system": "^2.4.6",
|
"@nextui-org/system": "^2.4.6",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
import Network from "./network";
|
import Network from "./network";
|
||||||
|
|
||||||
// 登陆相关接口
|
// 登陆相关接口
|
||||||
@@ -42,9 +44,17 @@ export const checkNodeStatus = (nodeId?: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const upgradeNode = (id: number, version?: string) =>
|
export const upgradeNode = (id: number, version?: string) =>
|
||||||
Network.post("/node/upgrade", { id, version: version || "" }, { timeout: 5 * 60 * 1000 });
|
Network.post(
|
||||||
|
"/node/upgrade",
|
||||||
|
{ id, version: version || "" },
|
||||||
|
{ timeout: 5 * 60 * 1000 },
|
||||||
|
);
|
||||||
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
export const batchUpgradeNodes = (ids: number[], version?: string) =>
|
||||||
Network.post("/node/batch-upgrade", { ids, version: version || "" }, { timeout: 15 * 60 * 1000 });
|
Network.post(
|
||||||
|
"/node/batch-upgrade",
|
||||||
|
{ ids, version: version || "" },
|
||||||
|
{ timeout: 15 * 60 * 1000 },
|
||||||
|
);
|
||||||
export const getNodeReleases = () => Network.post("/node/releases");
|
export const getNodeReleases = () => Network.post("/node/releases");
|
||||||
export const rollbackNode = (id: number) =>
|
export const rollbackNode = (id: number) =>
|
||||||
Network.post("/node/rollback", { id });
|
Network.post("/node/rollback", { id });
|
||||||
@@ -130,6 +140,10 @@ export const updateConfigs = (configMap: Record<string, string>) =>
|
|||||||
export const updateConfig = (name: string, value: string) =>
|
export const updateConfig = (name: string, value: string) =>
|
||||||
Network.post("/config/update-single", { name, value });
|
Network.post("/config/update-single", { name, value });
|
||||||
|
|
||||||
|
export const exportBackupData = () => Network.post("/backup/export");
|
||||||
|
export const importBackupData = (data: any) => Network.post("/backup/import", data);
|
||||||
|
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
|
||||||
|
|
||||||
// 验证码相关接口
|
// 验证码相关接口
|
||||||
export const checkCaptcha = () => Network.post("/captcha/check");
|
export const checkCaptcha = () => Network.post("/captcha/check");
|
||||||
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
export const generateCaptcha = () => Network.post(`/captcha/generate`);
|
||||||
@@ -224,7 +238,50 @@ export const resetPeerShareFlow = (id: number) =>
|
|||||||
Network.post("/federation/share/reset-flow", { id });
|
Network.post("/federation/share/reset-flow", { id });
|
||||||
export const getPeerRemoteUsageList = () =>
|
export const getPeerRemoteUsageList = () =>
|
||||||
Network.post("/federation/share/remote-usage/list");
|
Network.post("/federation/share/remote-usage/list");
|
||||||
export const importRemoteNode = (data: {
|
export const importRemoteNode = (data: { remoteUrl: string; token: string }) =>
|
||||||
remoteUrl: string;
|
Network.post("/federation/node/import", data);
|
||||||
token: string;
|
|
||||||
}) => Network.post("/federation/node/import", data);
|
export interface BackupTypes {
|
||||||
|
users?: boolean;
|
||||||
|
nodes?: boolean;
|
||||||
|
tunnels?: boolean;
|
||||||
|
forwards?: boolean;
|
||||||
|
userTunnels?: boolean;
|
||||||
|
speedLimits?: boolean;
|
||||||
|
tunnelGroups?: boolean;
|
||||||
|
userGroups?: boolean;
|
||||||
|
permissions?: boolean;
|
||||||
|
configs?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const exportBackup = async (types: string[] = []) => {
|
||||||
|
const token = window.localStorage.getItem("token");
|
||||||
|
const baseURL = axios.defaults.baseURL || "/api/v1/";
|
||||||
|
|
||||||
|
const response = await axios.post(
|
||||||
|
`${baseURL}/backup/export`,
|
||||||
|
{ types },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: token,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
responseType: "blob",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
|
||||||
|
link.href = url;
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[:-]/g, "");
|
||||||
|
|
||||||
|
link.setAttribute("download", `backup_${timestamp}.json`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const importBackup = (data: { types: string[]; [key: string]: any }) =>
|
||||||
|
Network.post("/backup/import", data);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { Button } from "@heroui/button";
|
import { Button } from "@heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@heroui/card";
|
import { Card, CardBody, CardHeader } from "@heroui/card";
|
||||||
@@ -7,9 +7,10 @@ import { Spinner } from "@heroui/spinner";
|
|||||||
import { Divider } from "@heroui/divider";
|
import { Divider } from "@heroui/divider";
|
||||||
import { Switch } from "@heroui/switch";
|
import { Switch } from "@heroui/switch";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
|
import { Checkbox, CheckboxGroup } from "@heroui/checkbox";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { updateConfigs } from "@/api";
|
import { updateConfigs, exportBackup, importBackup } from "@/api";
|
||||||
import { SettingsIcon } from "@/components/icons";
|
import { SettingsIcon } from "@/components/icons";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import {
|
import {
|
||||||
@@ -130,12 +131,19 @@ export default function ConfigPage() {
|
|||||||
useState<Record<string, string>>(initialConfigs);
|
useState<Record<string, string>>(initialConfigs);
|
||||||
const [loading, setLoading] = useState(
|
const [loading, setLoading] = useState(
|
||||||
Object.keys(initialConfigs).length === 0,
|
Object.keys(initialConfigs).length === 0,
|
||||||
); // 如果有缓存数据,不显示loading
|
);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [originalConfigs, setOriginalConfigs] =
|
const [originalConfigs, setOriginalConfigs] =
|
||||||
useState<Record<string, string>>(initialConfigs);
|
useState<Record<string, string>>(initialConfigs);
|
||||||
|
|
||||||
|
const [exportTypes, setExportTypes] = useState<string[]>([]);
|
||||||
|
const [importTypes, setImportTypes] = useState<string[]>([]);
|
||||||
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importFileName, setImportFileName] = useState("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// 权限检查
|
// 权限检查
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin()) {
|
if (!isAdmin()) {
|
||||||
@@ -331,6 +339,63 @@ export default function ConfigPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (exportTypes.length === 0) {
|
||||||
|
toast.error("请至少选择一种数据类型");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExporting(true);
|
||||||
|
try {
|
||||||
|
await exportBackup(exportTypes);
|
||||||
|
toast.success("导出成功");
|
||||||
|
} catch {
|
||||||
|
toast.error("导出失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (importTypes.length === 0) {
|
||||||
|
toast.error("请先选择要导入的数据类型");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportFileName(file.name);
|
||||||
|
setImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
|
||||||
|
const response = await importBackup({
|
||||||
|
types: importTypes,
|
||||||
|
...data,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.code === 0) {
|
||||||
|
toast.success(`导入成功: ${JSON.stringify(response.data)}`);
|
||||||
|
setImportTypes([]);
|
||||||
|
setImportFileName("");
|
||||||
|
} else {
|
||||||
|
toast.error("导入失败: " + response.msg);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("导入失败,请检查文件格式");
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
@@ -427,6 +492,140 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 备份与恢复 */}
|
||||||
|
<Card className="mt-6 shadow-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex justify-between items-center w-full">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">数据备份与恢复</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
导出或导入系统数据,支持选择特定数据类型
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
<CardBody className="space-y-6 pt-6">
|
||||||
|
{/* 导出部分 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">导出数据</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
选择要导出的数据类型,导出为 JSON 格式文件
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxGroup
|
||||||
|
classNames={{
|
||||||
|
wrapper: "gap-4",
|
||||||
|
}}
|
||||||
|
label="选择导出内容"
|
||||||
|
orientation="horizontal"
|
||||||
|
value={exportTypes}
|
||||||
|
onValueChange={(values) => setExportTypes(values as string[])}
|
||||||
|
>
|
||||||
|
<Checkbox value="users">用户</Checkbox>
|
||||||
|
<Checkbox value="nodes">节点</Checkbox>
|
||||||
|
<Checkbox value="tunnels">隧道</Checkbox>
|
||||||
|
<Checkbox value="forwards">转发</Checkbox>
|
||||||
|
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||||
|
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||||
|
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||||
|
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||||
|
<Checkbox value="permissions">分组权限</Checkbox>
|
||||||
|
<Checkbox value="configs">系统配置</Checkbox>
|
||||||
|
</CheckboxGroup>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={exporting}
|
||||||
|
onPress={handleExport}
|
||||||
|
>
|
||||||
|
{exporting ? "导出中..." : "导出数据"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="bordered"
|
||||||
|
onPress={() => {
|
||||||
|
setExportTypes([
|
||||||
|
"users",
|
||||||
|
"nodes",
|
||||||
|
"tunnels",
|
||||||
|
"forwards",
|
||||||
|
"userTunnels",
|
||||||
|
"speedLimits",
|
||||||
|
"tunnelGroups",
|
||||||
|
"userGroups",
|
||||||
|
"permissions",
|
||||||
|
"configs",
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Button>
|
||||||
|
<Button variant="flat" onPress={() => setExportTypes([])}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 导入部分 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium">导入数据</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
选择要导入的数据类型,支持从备份文件恢复数据
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<CheckboxGroup
|
||||||
|
classNames={{
|
||||||
|
wrapper: "gap-4",
|
||||||
|
}}
|
||||||
|
label="选择导入内容"
|
||||||
|
orientation="horizontal"
|
||||||
|
value={importTypes}
|
||||||
|
onValueChange={(values) => setImportTypes(values as string[])}
|
||||||
|
>
|
||||||
|
<Checkbox value="users">用户</Checkbox>
|
||||||
|
<Checkbox value="nodes">节点</Checkbox>
|
||||||
|
<Checkbox value="tunnels">隧道</Checkbox>
|
||||||
|
<Checkbox value="forwards">转发</Checkbox>
|
||||||
|
<Checkbox value="userTunnels">用户隧道权限</Checkbox>
|
||||||
|
<Checkbox value="speedLimits">限速规则</Checkbox>
|
||||||
|
<Checkbox value="tunnelGroups">隧道分组</Checkbox>
|
||||||
|
<Checkbox value="userGroups">用户分组</Checkbox>
|
||||||
|
<Checkbox value="permissions">分组权限</Checkbox>
|
||||||
|
<Checkbox value="configs">系统配置</Checkbox>
|
||||||
|
</CheckboxGroup>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
accept=".json"
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
isLoading={importing}
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{importing ? "导入中..." : "选择文件导入"}
|
||||||
|
</Button>
|
||||||
|
{importFileName && (
|
||||||
|
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
已选择: {importFileName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,8 +83,9 @@ export default function IndexPage() {
|
|||||||
if (response.code !== 0) {
|
if (response.code !== 0) {
|
||||||
toast.error(response.msg || "登录失败");
|
toast.error(response.msg || "登录失败");
|
||||||
if (showCaptcha) {
|
if (showCaptcha) {
|
||||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,11 +139,7 @@ export default function IndexPage() {
|
|||||||
} else {
|
} else {
|
||||||
const configResp = await getConfigByName("cloudflare_site_key");
|
const configResp = await getConfigByName("cloudflare_site_key");
|
||||||
|
|
||||||
if (
|
if (configResp.code === 0 && configResp.data && configResp.data.value) {
|
||||||
configResp.code === 0 &&
|
|
||||||
configResp.data &&
|
|
||||||
configResp.data.value
|
|
||||||
) {
|
|
||||||
setSiteKey(configResp.data.value);
|
setSiteKey(configResp.data.value);
|
||||||
setShowCaptcha(true);
|
setShowCaptcha(true);
|
||||||
} else {
|
} else {
|
||||||
@@ -241,8 +238,10 @@ export default function IndexPage() {
|
|||||||
{showCaptcha && siteKey && (
|
{showCaptcha && siteKey && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
{/* 背景遮罩层 - 模糊效果,暗黑模式下更深 */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
className="absolute inset-0 bg-black/60 dark:bg-black/80 backdrop-blur-sm captcha-backdrop-enter"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowCaptcha(false);
|
setShowCaptcha(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -253,32 +252,35 @@ export default function IndexPage() {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
/>
|
/>
|
||||||
{/* 验证码容器 */}
|
{/* 验证码容器 */}
|
||||||
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
|
<div className="mb-4 relative z-50 bg-white dark:bg-zinc-900 p-6 rounded-lg shadow-xl">
|
||||||
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">请完成安全验证</div>
|
<div className="mb-4 text-center text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
请完成安全验证
|
||||||
|
</div>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<Turnstile
|
<Turnstile
|
||||||
siteKey={siteKey}
|
options={{
|
||||||
onSuccess={(token) => {
|
theme: (document.documentElement.classList.contains(
|
||||||
setForm((prev) => ({ ...prev, captchaId: token }));
|
"dark",
|
||||||
void performLogin(token);
|
) ||
|
||||||
|
document.documentElement.getAttribute("data-theme") ===
|
||||||
|
"dark" ||
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light") as "light" | "dark" | "auto",
|
||||||
}}
|
}}
|
||||||
|
siteKey={siteKey}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
toast.error("验证失败,请刷新重试");
|
toast.error("验证失败,请刷新重试");
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}}
|
}}
|
||||||
onExpire={() => {
|
onExpire={() => {
|
||||||
setForm((prev) => ({ ...prev, captchaId: "" }));
|
setForm((prev) => ({ ...prev, captchaId: "" }));
|
||||||
}}
|
}}
|
||||||
options={{
|
onSuccess={(token) => {
|
||||||
theme: (document.documentElement.classList.contains("dark") ||
|
setForm((prev) => ({ ...prev, captchaId: token }));
|
||||||
document.documentElement.getAttribute("data-theme") === "dark" ||
|
void performLogin(token);
|
||||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
||||||
? "dark"
|
|
||||||
: "light") as "light" | "dark" | "auto"
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+378
-316
@@ -125,7 +125,12 @@ const SortableItem = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} className="overflow-hidden">
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...attributes}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
{children(listeners)}
|
{children(listeners)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -174,13 +179,26 @@ export default function NodePage() {
|
|||||||
|
|
||||||
// 升级相关状态
|
// 升级相关状态
|
||||||
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
|
||||||
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">("single");
|
const [upgradeTarget, setUpgradeTarget] = useState<"single" | "batch">(
|
||||||
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(null);
|
"single",
|
||||||
const [releases, setReleases] = useState<Array<{ version: string; name: string; publishedAt: string; prerelease: boolean }>>([]);
|
);
|
||||||
|
const [upgradeTargetNodeId, setUpgradeTargetNodeId] = useState<number | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [releases, setReleases] = useState<
|
||||||
|
Array<{
|
||||||
|
version: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string;
|
||||||
|
prerelease: boolean;
|
||||||
|
}>
|
||||||
|
>([]);
|
||||||
const [releasesLoading, setReleasesLoading] = useState(false);
|
const [releasesLoading, setReleasesLoading] = useState(false);
|
||||||
const [selectedVersion, setSelectedVersion] = useState("");
|
const [selectedVersion, setSelectedVersion] = useState("");
|
||||||
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
const [batchUpgradeLoading, setBatchUpgradeLoading] = useState(false);
|
||||||
const [upgradeProgress, setUpgradeProgress] = useState<Record<number, { stage: string; percent: number; message: string }>>({});
|
const [upgradeProgress, setUpgradeProgress] = useState<
|
||||||
|
Record<number, { stage: string; percent: number; message: string }>
|
||||||
|
>({});
|
||||||
|
|
||||||
const websocketRef = useRef<WebSocket | null>(null);
|
const websocketRef = useRef<WebSocket | null>(null);
|
||||||
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
const reconnectTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -237,7 +255,11 @@ export default function NodePage() {
|
|||||||
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
const nodesData: Node[] = (res.data || []).map((node: any) => ({
|
||||||
...node,
|
...node,
|
||||||
inx: node.inx ?? 0,
|
inx: node.inx ?? 0,
|
||||||
connectionStatus: node.syncError ? "offline" : node.status === 1 ? "online" : "offline",
|
connectionStatus: node.syncError
|
||||||
|
? "offline"
|
||||||
|
: node.status === 1
|
||||||
|
? "online"
|
||||||
|
: "offline",
|
||||||
syncError: node.syncError || undefined,
|
syncError: node.syncError || undefined,
|
||||||
systemInfo: null,
|
systemInfo: null,
|
||||||
copyLoading: false,
|
copyLoading: false,
|
||||||
@@ -444,7 +466,11 @@ export default function NodePage() {
|
|||||||
);
|
);
|
||||||
} else if (type === "upgrade_progress") {
|
} else if (type === "upgrade_progress") {
|
||||||
try {
|
try {
|
||||||
const progressData = typeof messageData === "string" ? JSON.parse(messageData) : messageData;
|
const progressData =
|
||||||
|
typeof messageData === "string"
|
||||||
|
? JSON.parse(messageData)
|
||||||
|
: messageData;
|
||||||
|
|
||||||
if (progressData?.data) {
|
if (progressData?.data) {
|
||||||
setUpgradeProgress((prev) => ({
|
setUpgradeProgress((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -803,10 +829,11 @@ export default function NodePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 打开版本选择弹窗
|
// 打开版本选择弹窗
|
||||||
const openUpgradeModal = async (target: "single" | "batch", nodeId?: number) => {
|
const openUpgradeModal = async (
|
||||||
|
target: "single" | "batch",
|
||||||
|
nodeId?: number,
|
||||||
|
) => {
|
||||||
setUpgradeTarget(target);
|
setUpgradeTarget(target);
|
||||||
setUpgradeTargetNodeId(nodeId || null);
|
setUpgradeTargetNodeId(nodeId || null);
|
||||||
setSelectedVersion("");
|
setSelectedVersion("");
|
||||||
@@ -814,6 +841,7 @@ export default function NodePage() {
|
|||||||
setReleasesLoading(true);
|
setReleasesLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getNodeReleases();
|
const res = await getNodeReleases();
|
||||||
|
|
||||||
if (res.code === 0 && Array.isArray(res.data)) {
|
if (res.code === 0 && Array.isArray(res.data)) {
|
||||||
setReleases(res.data);
|
setReleases(res.data);
|
||||||
} else {
|
} else {
|
||||||
@@ -829,16 +857,21 @@ export default function NodePage() {
|
|||||||
// 确认升级(从版本弹窗)
|
// 确认升级(从版本弹窗)
|
||||||
const handleConfirmUpgrade = async () => {
|
const handleConfirmUpgrade = async () => {
|
||||||
const version = selectedVersion || undefined;
|
const version = selectedVersion || undefined;
|
||||||
|
|
||||||
if (upgradeTarget === "single" && upgradeTargetNodeId) {
|
if (upgradeTarget === "single" && upgradeTargetNodeId) {
|
||||||
setUpgradeModalOpen(false);
|
setUpgradeModalOpen(false);
|
||||||
// Find the node
|
// Find the node
|
||||||
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
|
const node = nodeList.find((n) => n.id === upgradeTargetNodeId);
|
||||||
|
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
setNodeList((prev) =>
|
setNodeList((prev) =>
|
||||||
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n)),
|
prev.map((n) =>
|
||||||
|
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: true } : n,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await upgradeNode(upgradeTargetNodeId, version);
|
const res = await upgradeNode(upgradeTargetNodeId, version);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(`节点升级命令已发送,节点将自动重启`);
|
toast.success(`节点升级命令已发送,节点将自动重启`);
|
||||||
} else {
|
} else {
|
||||||
@@ -848,7 +881,9 @@ export default function NodePage() {
|
|||||||
toast.error("网络错误,请重试");
|
toast.error("网络错误,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setNodeList((prev) =>
|
setNodeList((prev) =>
|
||||||
prev.map((n) => (n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n)),
|
prev.map((n) =>
|
||||||
|
n.id === upgradeTargetNodeId ? { ...n, upgradeLoading: false } : n,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (upgradeTarget === "batch") {
|
} else if (upgradeTarget === "batch") {
|
||||||
@@ -856,6 +891,7 @@ export default function NodePage() {
|
|||||||
setUpgradeModalOpen(false);
|
setUpgradeModalOpen(false);
|
||||||
try {
|
try {
|
||||||
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
const res = await batchUpgradeNodes(Array.from(selectedIds), version);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
toast.success(`批量升级命令已发送到 ${selectedIds.size} 个节点`);
|
||||||
} else {
|
} else {
|
||||||
@@ -876,6 +912,7 @@ export default function NodePage() {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const res = await rollbackNode(node.id);
|
const res = await rollbackNode(node.id);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
|
toast.success(`节点 ${node.name} 回退命令已发送,节点将自动重启`);
|
||||||
} else {
|
} else {
|
||||||
@@ -885,7 +922,9 @@ export default function NodePage() {
|
|||||||
toast.error("网络错误,请重试");
|
toast.error("网络错误,请重试");
|
||||||
} finally {
|
} finally {
|
||||||
setNodeList((prev) =>
|
setNodeList((prev) =>
|
||||||
prev.map((n) => (n.id === node.id ? { ...n, rollbackLoading: false } : n)),
|
prev.map((n) =>
|
||||||
|
n.id === node.id ? { ...n, rollbackLoading: false } : n,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1251,332 +1290,349 @@ export default function NodePage() {
|
|||||||
return (
|
return (
|
||||||
<SortableItem key={node.id} id={node.id}>
|
<SortableItem key={node.id} id={node.id}>
|
||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
key={node.id}
|
||||||
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
className="group shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<div className="flex justify-between items-start w-full">
|
<div className="flex justify-between items-start w-full">
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
isSelected={selectedIds.has(node.id)}
|
isSelected={selectedIds.has(node.id)}
|
||||||
onValueChange={() => toggleSelect(node.id)}
|
onValueChange={() => toggleSelect(node.id)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<h3 className="font-semibold text-foreground truncate text-sm">
|
<h3 className="font-semibold text-foreground truncate text-sm">
|
||||||
{node.name}
|
{node.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 ml-2">
|
|
||||||
<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}
|
|
||||||
style={{ touchAction: "none" }}
|
|
||||||
title="拖拽排序"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
{isRemoteNode && (
|
<div className="flex items-center gap-1.5 ml-2">
|
||||||
|
<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}
|
||||||
|
style={{ touchAction: "none" }}
|
||||||
|
title="拖拽排序"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<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
|
<Chip
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
color="secondary"
|
color={
|
||||||
|
node.connectionStatus === "online"
|
||||||
|
? "success"
|
||||||
|
: "danger"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
远程
|
{node.connectionStatus === "online"
|
||||||
|
? "在线"
|
||||||
|
: "离线"}
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
|
||||||
<Chip
|
|
||||||
className="text-xs"
|
|
||||||
color={
|
|
||||||
node.connectionStatus === "online"
|
|
||||||
? "success"
|
|
||||||
: "danger"
|
|
||||||
}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
>
|
|
||||||
{node.connectionStatus === "online"
|
|
||||||
? "在线"
|
|
||||||
: "离线"}
|
|
||||||
</Chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardBody className="pt-0 pb-3">
|
|
||||||
{isRemoteNode && node.syncError && (
|
|
||||||
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
|
||||||
{node.syncError === "provider_share_deleted"
|
|
||||||
? "提供方已删除该分享"
|
|
||||||
: node.syncError === "provider_share_disabled"
|
|
||||||
? "提供方已禁用该分享"
|
|
||||||
: node.syncError === "provider_share_expired"
|
|
||||||
? "提供方分享已过期"
|
|
||||||
: `远程同步失败: ${node.syncError}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 基础信息 */}
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex justify-between items-center text-sm min-w-0">
|
|
||||||
<span className="text-default-600 flex-shrink-0">
|
|
||||||
IP
|
|
||||||
</span>
|
|
||||||
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
|
||||||
{node.serverIpV4?.trim() ||
|
|
||||||
node.serverIpV6?.trim() ? (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{node.serverIpV4?.trim() && (
|
|
||||||
<span
|
|
||||||
className="font-mono truncate block"
|
|
||||||
title={node.serverIpV4.trim()}
|
|
||||||
>
|
|
||||||
{node.serverIpV4.trim()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{node.serverIpV6?.trim() && (
|
|
||||||
<span
|
|
||||||
className="font-mono truncate block"
|
|
||||||
title={node.serverIpV6.trim()}
|
|
||||||
>
|
|
||||||
{node.serverIpV6.trim()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className="font-mono truncate block"
|
|
||||||
title={node.serverIp.trim()}
|
|
||||||
>
|
|
||||||
{node.serverIp.trim()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardBody className="pt-0 pb-3">
|
||||||
|
{isRemoteNode && node.syncError && (
|
||||||
|
<div className="mb-3 px-2 py-1.5 rounded-md bg-warning-50 dark:bg-warning-100/10 text-warning-700 dark:text-warning-400 text-xs">
|
||||||
|
{node.syncError === "provider_share_deleted"
|
||||||
|
? "提供方已删除该分享"
|
||||||
|
: node.syncError === "provider_share_disabled"
|
||||||
|
? "提供方已禁用该分享"
|
||||||
|
: node.syncError === "provider_share_expired"
|
||||||
|
? "提供方分享已过期"
|
||||||
|
: `远程同步失败: ${node.syncError}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* 基础信息 */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<div className="flex justify-between items-center text-sm min-w-0">
|
||||||
|
<span className="text-default-600 flex-shrink-0">
|
||||||
|
IP
|
||||||
|
</span>
|
||||||
|
<div className="text-right text-xs min-w-0 flex-1 ml-2">
|
||||||
|
{node.serverIpV4?.trim() ||
|
||||||
|
node.serverIpV6?.trim() ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{node.serverIpV4?.trim() && (
|
||||||
|
<span
|
||||||
|
className="font-mono truncate block"
|
||||||
|
title={node.serverIpV4.trim()}
|
||||||
|
>
|
||||||
|
{node.serverIpV4.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{node.serverIpV6?.trim() && (
|
||||||
|
<span
|
||||||
|
className="font-mono truncate block"
|
||||||
|
title={node.serverIpV6.trim()}
|
||||||
|
>
|
||||||
|
{node.serverIpV6.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-mono truncate block"
|
||||||
|
title={node.serverIp.trim()}
|
||||||
|
>
|
||||||
|
{node.serverIp.trim()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isRemoteNode && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-default-600">版本</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{node.version || "未知"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{upgradeProgress[node.id] &&
|
||||||
|
upgradeProgress[node.id].percent < 100 && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Progress
|
||||||
|
showValueLabel
|
||||||
|
aria-label="升级进度"
|
||||||
|
color="warning"
|
||||||
|
label={upgradeProgress[node.id].message}
|
||||||
|
size="sm"
|
||||||
|
value={upgradeProgress[node.id].percent}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between text-sm">
|
{/* 系统监控 */}
|
||||||
<span className="text-default-600">版本</span>
|
<div className="space-y-3 mb-4">
|
||||||
<span className="text-xs">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
{node.version || "未知"}
|
<div>
|
||||||
</span>
|
<div className="flex justify-between text-xs mb-1">
|
||||||
</div>
|
<span>CPU</span>
|
||||||
{upgradeProgress[node.id] && upgradeProgress[node.id].percent < 100 && (
|
<span className="font-mono">
|
||||||
<div className="mt-1">
|
{node.connectionStatus === "online" &&
|
||||||
<Progress
|
node.systemInfo
|
||||||
aria-label="升级进度"
|
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||||
color="warning"
|
: "-"}
|
||||||
label={upgradeProgress[node.id].message}
|
</span>
|
||||||
showValueLabel
|
</div>
|
||||||
size="sm"
|
<Progress
|
||||||
value={upgradeProgress[node.id].percent}
|
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="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>
|
|
||||||
|
|
||||||
{!isRemoteNode && (
|
{/* 操作按钮 */}
|
||||||
<>
|
<div className="space-y-1.5">
|
||||||
{/* 系统监控 */}
|
|
||||||
<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">
|
|
||||||
{!isRemoteNode && (
|
|
||||||
<div className="grid grid-cols-3 gap-1.5">
|
|
||||||
<Button
|
|
||||||
className="min-h-8"
|
|
||||||
color="success"
|
|
||||||
isLoading={node.copyLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleCopyInstallCommand(node)}
|
|
||||||
>
|
|
||||||
安装
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="min-h-8"
|
|
||||||
color="warning"
|
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
|
||||||
isLoading={node.upgradeLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => openUpgradeModal("single", node.id)}
|
|
||||||
>
|
|
||||||
升级
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="min-h-8"
|
|
||||||
color="secondary"
|
|
||||||
isDisabled={node.connectionStatus !== "online"}
|
|
||||||
isLoading={node.rollbackLoading}
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleRollbackNode(node)}
|
|
||||||
>
|
|
||||||
回退
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}>
|
|
||||||
{!isRemoteNode && (
|
{!isRemoteNode && (
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="success"
|
||||||
|
isLoading={node.copyLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleCopyInstallCommand(node)}
|
||||||
|
>
|
||||||
|
安装
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="warning"
|
||||||
|
isDisabled={
|
||||||
|
node.connectionStatus !== "online"
|
||||||
|
}
|
||||||
|
isLoading={node.upgradeLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() =>
|
||||||
|
openUpgradeModal("single", node.id)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
升级
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="secondary"
|
||||||
|
isDisabled={
|
||||||
|
node.connectionStatus !== "online"
|
||||||
|
}
|
||||||
|
isLoading={node.rollbackLoading}
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleRollbackNode(node)}
|
||||||
|
>
|
||||||
|
回退
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`grid gap-1.5 ${isRemoteNode ? "grid-cols-1" : "grid-cols-2"}`}
|
||||||
|
>
|
||||||
|
{!isRemoteNode && (
|
||||||
|
<Button
|
||||||
|
className="min-h-8"
|
||||||
|
color="primary"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleEdit(node)}
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
className="min-h-8"
|
className="min-h-8"
|
||||||
color="primary"
|
color="danger"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEdit(node)}
|
onPress={() => handleDelete(node)}
|
||||||
>
|
>
|
||||||
编辑
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
className="min-h-8"
|
|
||||||
color="danger"
|
|
||||||
size="sm"
|
|
||||||
variant="flat"
|
|
||||||
onPress={() => handleDelete(node)}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardBody>
|
||||||
</CardBody>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
)}
|
</SortableItem>
|
||||||
</SortableItem>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -2041,6 +2097,7 @@ export default function NodePage() {
|
|||||||
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
selectedKeys={selectedVersion ? [selectedVersion] : []}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const selected = Array.from(keys)[0] as string;
|
const selected = Array.from(keys)[0] as string;
|
||||||
|
|
||||||
setSelectedVersion(selected || "");
|
setSelectedVersion(selected || "");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -2053,7 +2110,12 @@ export default function NodePage() {
|
|||||||
? new Date(r.publishedAt).toLocaleDateString()
|
? new Date(r.publishedAt).toLocaleDateString()
|
||||||
: ""}
|
: ""}
|
||||||
{r.prerelease && (
|
{r.prerelease && (
|
||||||
<Chip className="ml-1" color="warning" size="sm" variant="flat">
|
<Chip
|
||||||
|
className="ml-1"
|
||||||
|
color="warning"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
预览
|
预览
|
||||||
</Chip>
|
</Chip>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@heroui/modal";
|
} from "@heroui/modal";
|
||||||
import { Select, SelectItem } from "@heroui/select";
|
import { Select, SelectItem } from "@heroui/select";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getNodeList,
|
getNodeList,
|
||||||
createPeerShare,
|
createPeerShare,
|
||||||
@@ -128,6 +129,7 @@ export default function PanelSharingPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPeerShareList();
|
const res = await getPeerShareList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setShares(res.data || []);
|
setShares(res.data || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -141,10 +143,12 @@ export default function PanelSharingPage() {
|
|||||||
const loadNodes = useCallback(async () => {
|
const loadNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getNodeList();
|
const res = await getNodeList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
const localNodes: Node[] = (res.data || []).filter(
|
const localNodes: Node[] = (res.data || []).filter(
|
||||||
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
(node: Node) => (node?.isRemote ?? 0) !== 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
setNodes(localNodes);
|
setNodes(localNodes);
|
||||||
setShareForm((prev) => {
|
setShareForm((prev) => {
|
||||||
if (!prev.nodeId) {
|
if (!prev.nodeId) {
|
||||||
@@ -153,6 +157,7 @@ export default function PanelSharingPage() {
|
|||||||
const hasSelectedNode = localNodes.some(
|
const hasSelectedNode = localNodes.some(
|
||||||
(node: Node) => String(node.id) === prev.nodeId,
|
(node: Node) => String(node.id) === prev.nodeId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
return hasSelectedNode ? prev : { ...prev, nodeId: "" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -165,6 +170,7 @@ export default function PanelSharingPage() {
|
|||||||
setRemoteUsageLoading(true);
|
setRemoteUsageLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await getPeerRemoteUsageList();
|
const res = await getPeerRemoteUsageList();
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
setRemoteUsageNodes(res.data || []);
|
setRemoteUsageNodes(res.data || []);
|
||||||
} else {
|
} else {
|
||||||
@@ -179,6 +185,7 @@ export default function PanelSharingPage() {
|
|||||||
if (selectedTab === "my-shares") {
|
if (selectedTab === "my-shares") {
|
||||||
loadShares();
|
loadShares();
|
||||||
loadNodes();
|
loadNodes();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (selectedTab === "remote-nodes") {
|
if (selectedTab === "remote-nodes") {
|
||||||
@@ -189,15 +196,19 @@ export default function PanelSharingPage() {
|
|||||||
const handleCreateShare = async () => {
|
const handleCreateShare = async () => {
|
||||||
if (!shareForm.name || !shareForm.nodeId) {
|
if (!shareForm.name || !shareForm.nodeId) {
|
||||||
toast.error("请填写必要信息");
|
toast.error("请填写必要信息");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nodeId = parseInt(shareForm.nodeId, 10);
|
const nodeId = parseInt(shareForm.nodeId, 10);
|
||||||
|
|
||||||
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
if (Number.isNaN(nodeId) || !nodes.some((node) => node.id === nodeId)) {
|
||||||
toast.error("仅可选择本地节点");
|
toast.error("仅可选择本地节点");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (shareForm.maxBandwidth < 0) {
|
if (shareForm.maxBandwidth < 0) {
|
||||||
toast.error("流量上限不能为负数");
|
toast.error("流量上限不能为负数");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -213,6 +224,7 @@ export default function PanelSharingPage() {
|
|||||||
allowedDomains: shareForm.allowedDomains,
|
allowedDomains: shareForm.allowedDomains,
|
||||||
allowedIps: shareForm.allowedIps,
|
allowedIps: shareForm.allowedIps,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("创建成功");
|
toast.success("创建成功");
|
||||||
setCreateShareOpen(false);
|
setCreateShareOpen(false);
|
||||||
@@ -228,6 +240,7 @@ export default function PanelSharingPage() {
|
|||||||
const handleDeleteShare = async (id: number) => {
|
const handleDeleteShare = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await deletePeerShare(id);
|
const res = await deletePeerShare(id);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
loadShares();
|
loadShares();
|
||||||
@@ -242,6 +255,7 @@ export default function PanelSharingPage() {
|
|||||||
const handleResetShareFlow = async (id: number) => {
|
const handleResetShareFlow = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
const res = await resetPeerShareFlow(id);
|
const res = await resetPeerShareFlow(id);
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("共享流量已重置");
|
toast.success("共享流量已重置");
|
||||||
loadShares();
|
loadShares();
|
||||||
@@ -257,7 +271,10 @@ export default function PanelSharingPage() {
|
|||||||
setEditForm({
|
setEditForm({
|
||||||
id: share.id,
|
id: share.id,
|
||||||
name: share.name,
|
name: share.name,
|
||||||
maxBandwidth: share.maxBandwidth > 0 ? Math.round(share.maxBandwidth / (1024 * 1024 * 1024)) : 0,
|
maxBandwidth:
|
||||||
|
share.maxBandwidth > 0
|
||||||
|
? Math.round(share.maxBandwidth / (1024 * 1024 * 1024))
|
||||||
|
: 0,
|
||||||
expiryTime: share.expiryTime,
|
expiryTime: share.expiryTime,
|
||||||
portRangeStart: share.portRangeStart,
|
portRangeStart: share.portRangeStart,
|
||||||
portRangeEnd: share.portRangeEnd,
|
portRangeEnd: share.portRangeEnd,
|
||||||
@@ -270,10 +287,12 @@ export default function PanelSharingPage() {
|
|||||||
const handleEditShare = async () => {
|
const handleEditShare = async () => {
|
||||||
if (!editForm.name) {
|
if (!editForm.name) {
|
||||||
toast.error("名称不能为空");
|
toast.error("名称不能为空");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editForm.maxBandwidth < 0) {
|
if (editForm.maxBandwidth < 0) {
|
||||||
toast.error("流量上限不能为负数");
|
toast.error("流量上限不能为负数");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -287,6 +306,7 @@ export default function PanelSharingPage() {
|
|||||||
allowedDomains: editForm.allowedDomains,
|
allowedDomains: editForm.allowedDomains,
|
||||||
allowedIps: editForm.allowedIps,
|
allowedIps: editForm.allowedIps,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("编辑成功");
|
toast.success("编辑成功");
|
||||||
setEditShareOpen(false);
|
setEditShareOpen(false);
|
||||||
@@ -302,19 +322,22 @@ export default function PanelSharingPage() {
|
|||||||
const handleImportNode = async () => {
|
const handleImportNode = async () => {
|
||||||
if (!importForm.remoteUrl || !importForm.token) {
|
if (!importForm.remoteUrl || !importForm.token) {
|
||||||
toast.error("请填写完整信息");
|
toast.error("请填写完整信息");
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Automatically add http/https if missing
|
// Automatically add http/https if missing
|
||||||
let url = importForm.remoteUrl.trim();
|
let url = importForm.remoteUrl.trim();
|
||||||
|
|
||||||
if (!url.startsWith("http")) {
|
if (!url.startsWith("http")) {
|
||||||
url = "http://" + url;
|
url = "http://" + url;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await importRemoteNode({
|
const res = await importRemoteNode({
|
||||||
remoteUrl: url,
|
remoteUrl: url,
|
||||||
token: importForm.token.trim(),
|
token: importForm.token.trim(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.code === 0) {
|
if (res.code === 0) {
|
||||||
toast.success("导入成功,请前往节点列表查看");
|
toast.success("导入成功,请前往节点列表查看");
|
||||||
setImportNodeOpen(false);
|
setImportNodeOpen(false);
|
||||||
@@ -341,6 +364,7 @@ export default function PanelSharingPage() {
|
|||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + " KB";
|
||||||
if (bytes < 1024 * 1024 * 1024)
|
if (bytes < 1024 * 1024 * 1024)
|
||||||
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
return (bytes / (1024 * 1024)).toFixed(2) + " MB";
|
||||||
|
|
||||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -351,6 +375,7 @@ export default function PanelSharingPage() {
|
|||||||
if (chainType === 3) {
|
if (chainType === 3) {
|
||||||
return "出口节点";
|
return "出口节点";
|
||||||
}
|
}
|
||||||
|
|
||||||
return "未知链路";
|
return "未知链路";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -370,11 +395,14 @@ export default function PanelSharingPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button color="primary" onPress={() => setCreateShareOpen(true)}>
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onPress={() => setCreateShareOpen(true)}
|
||||||
|
>
|
||||||
创建分享
|
创建分享
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="text-center py-10 text-gray-500">加载中...</div>
|
<div className="text-center py-10 text-gray-500">加载中...</div>
|
||||||
) : shares.length === 0 ? (
|
) : shares.length === 0 ? (
|
||||||
@@ -382,7 +410,10 @@ export default function PanelSharingPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{shares.map((share) => (
|
{shares.map((share) => (
|
||||||
<Card key={share.id} className="border border-divider shadow-sm">
|
<Card
|
||||||
|
key={share.id}
|
||||||
|
className="border border-divider shadow-sm"
|
||||||
|
>
|
||||||
<CardHeader className="flex justify-between">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{share.name}</h3>
|
<h3 className="font-bold">{share.name}</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -400,29 +431,67 @@ export default function PanelSharingPage() {
|
|||||||
>
|
>
|
||||||
重置流量
|
重置流量
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color="danger" variant="flat" onPress={() => handleDeleteShare(share.id)}>删除</Button>
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
variant="flat"
|
||||||
|
onPress={() => handleDeleteShare(share.id)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
<p>端口范围: {share.portRangeStart} - {share.portRangeEnd}</p>
|
<p>
|
||||||
<p>流量上限: {share.maxBandwidth > 0 ? formatFlowGB(share.maxBandwidth) : "不限制"}</p>
|
端口范围: {share.portRangeStart} -{" "}
|
||||||
|
{share.portRangeEnd}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
流量上限:{" "}
|
||||||
|
{share.maxBandwidth > 0
|
||||||
|
? formatFlowGB(share.maxBandwidth)
|
||||||
|
: "不限制"}
|
||||||
|
</p>
|
||||||
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
<p>当前流量: {formatFlowGB(share.currentFlow || 0)}</p>
|
||||||
<p>远程占用端口: {share.usedPorts && share.usedPorts.length > 0 ? share.usedPorts.join(", ") : "暂无"}</p>
|
<p>
|
||||||
{share.usedPortDetails && share.usedPortDetails.length > 0 && (
|
远程占用端口:{" "}
|
||||||
<div className="flex flex-wrap gap-2">
|
{share.usedPorts && share.usedPorts.length > 0
|
||||||
{share.usedPortDetails.map((item) => (
|
? share.usedPorts.join(", ")
|
||||||
<span key={item.runtimeId} className="text-xs rounded-full px-2 py-1 bg-default-100">
|
: "暂无"}
|
||||||
{item.port} / {item.role || "reserved"}
|
</p>
|
||||||
</span>
|
{share.usedPortDetails &&
|
||||||
))}
|
share.usedPortDetails.length > 0 && (
|
||||||
</div>
|
<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.allowedDomains && <p>允许域名: {share.allowedDomains}</p>}
|
{share.allowedIps && (
|
||||||
{share.allowedIps && <p>允许API IP: {share.allowedIps}</p>}
|
<p>允许API IP: {share.allowedIps}</p>
|
||||||
<p>过期时间: {share.expiryTime === 0 ? "永久" : new Date(share.expiryTime).toLocaleDateString()}</p>
|
)}
|
||||||
|
<p>
|
||||||
|
过期时间:{" "}
|
||||||
|
{share.expiryTime === 0
|
||||||
|
? "永久"
|
||||||
|
: new Date(share.expiryTime).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input readOnly size="sm" value={share.token} />
|
<Input readOnly size="sm" value={share.token} />
|
||||||
<Button size="sm" onPress={() => copyToken(share.token)}>复制</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onPress={() => copyToken(share.token)}
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -436,7 +505,10 @@ export default function PanelSharingPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Button color="secondary" onPress={() => setImportNodeOpen(true)}>
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
onPress={() => setImportNodeOpen(true)}
|
||||||
|
>
|
||||||
导入远程节点
|
导入远程节点
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -446,15 +518,22 @@ export default function PanelSharingPage() {
|
|||||||
) : remoteUsageNodes.length === 0 ? (
|
) : remoteUsageNodes.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500">
|
<div className="text-center py-10 text-gray-500">
|
||||||
<p>暂无远程节点占用记录。</p>
|
<p>暂无远程节点占用记录。</p>
|
||||||
<p className="mt-2">导入远程节点并创建隧道后,这里会显示远端端口占用情况。</p>
|
<p className="mt-2">
|
||||||
|
导入远程节点并创建隧道后,这里会显示远端端口占用情况。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{remoteUsageNodes.map((node) => (
|
{remoteUsageNodes.map((node) => (
|
||||||
<Card key={node.nodeId} className="border border-divider shadow-sm">
|
<Card
|
||||||
|
key={node.nodeId}
|
||||||
|
className="border border-divider shadow-sm"
|
||||||
|
>
|
||||||
<CardHeader className="flex justify-between">
|
<CardHeader className="flex justify-between">
|
||||||
<h3 className="font-bold">{node.nodeName}</h3>
|
<h3 className="font-bold">{node.nodeName}</h3>
|
||||||
<span className="text-xs text-default-500">绑定 {node.activeBindingNum || 0}</span>
|
<span className="text-xs text-default-500">
|
||||||
|
绑定 {node.activeBindingNum || 0}
|
||||||
|
</span>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardBody className="text-sm space-y-2">
|
<CardBody className="text-sm space-y-2">
|
||||||
{node.syncError && (
|
{node.syncError && (
|
||||||
@@ -470,18 +549,40 @@ export default function PanelSharingPage() {
|
|||||||
)}
|
)}
|
||||||
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
{node.remoteUrl && <p>远程地址: {node.remoteUrl}</p>}
|
||||||
<p>共享ID: {node.shareId || "-"}</p>
|
<p>共享ID: {node.shareId || "-"}</p>
|
||||||
<p>端口范围: {node.portRangeStart > 0 && node.portRangeEnd > 0 ? `${node.portRangeStart} - ${node.portRangeEnd}` : "-"}</p>
|
<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.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 && (
|
{node.bindings && node.bindings.length > 0 && (
|
||||||
<div className="space-y-1 pt-1">
|
<div className="space-y-1 pt-1">
|
||||||
{node.bindings.map((binding) => (
|
{node.bindings.map((binding) => (
|
||||||
<p key={binding.bindingId} className="text-xs text-default-600">
|
<p
|
||||||
隧道 {binding.tunnelName || `#${binding.tunnelId}`}
|
key={binding.bindingId}
|
||||||
|
className="text-xs text-default-600"
|
||||||
|
>
|
||||||
|
隧道{" "}
|
||||||
|
{binding.tunnelName || `#${binding.tunnelId}`}
|
||||||
{" · "}
|
{" · "}
|
||||||
端口 {binding.allocatedPort}
|
端口 {binding.allocatedPort}
|
||||||
{" · "}
|
{" · "}
|
||||||
{formatChainType(binding.chainType, binding.hopInx)}
|
{formatChainType(
|
||||||
|
binding.chainType,
|
||||||
|
binding.hopInx,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -505,13 +606,17 @@ export default function PanelSharingPage() {
|
|||||||
label="名称"
|
label="名称"
|
||||||
placeholder="备注名称"
|
placeholder="备注名称"
|
||||||
value={shareForm.name}
|
value={shareForm.name}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, name: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="选择节点"
|
label="选择节点"
|
||||||
placeholder="选择要分享的本地节点"
|
placeholder="选择要分享的本地节点"
|
||||||
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
selectedKeys={shareForm.nodeId ? [shareForm.nodeId] : []}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, nodeId: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, nodeId: e.target.value })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{nodes.map((node) => (
|
{nodes.map((node) => (
|
||||||
<SelectItem key={node.id} textValue={node.name}>
|
<SelectItem key={node.id} textValue={node.name}>
|
||||||
@@ -524,47 +629,73 @@ export default function PanelSharingPage() {
|
|||||||
label="起始端口"
|
label="起始端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={shareForm.portRangeStart.toString()}
|
value={shareForm.portRangeStart.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, portRangeStart: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
portRangeStart: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="结束端口"
|
label="结束端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={shareForm.portRangeEnd.toString()}
|
value={shareForm.portRangeEnd.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, portRangeEnd: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
portRangeEnd: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示永久"
|
||||||
label="有效期 (天)"
|
label="有效期 (天)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示永久"
|
|
||||||
value={shareForm.expiryDays.toString()}
|
value={shareForm.expiryDays.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, expiryDays: parseInt(e.target.value) })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
expiryDays: parseInt(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示不限流量"
|
||||||
label="流量上限 (GB)"
|
label="流量上限 (GB)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示不限流量"
|
|
||||||
value={shareForm.maxBandwidth.toString()}
|
value={shareForm.maxBandwidth.toString()}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
onChange={(e) =>
|
||||||
|
setShareForm({
|
||||||
|
...shareForm,
|
||||||
|
maxBandwidth: parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
label="允许的域名 (可选)"
|
label="允许的域名 (可选)"
|
||||||
placeholder="example.com, panel.test.com"
|
placeholder="example.com, panel.test.com"
|
||||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
|
||||||
value={shareForm.allowedDomains}
|
value={shareForm.allowedDomains}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, allowedDomains: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, allowedDomains: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
label="允许的API IP (可选)"
|
label="允许的API IP (可选)"
|
||||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
|
||||||
value={shareForm.allowedIps}
|
value={shareForm.allowedIps}
|
||||||
onChange={(e) => setShareForm({ ...shareForm, allowedIps: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setShareForm({ ...shareForm, allowedIps: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
<Button onPress={() => setCreateShareOpen(false)}>取消</Button>
|
||||||
<Button color="primary" onPress={handleCreateShare}>创建</Button>
|
<Button color="primary" onPress={handleCreateShare}>
|
||||||
|
创建
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -578,54 +709,88 @@ export default function PanelSharingPage() {
|
|||||||
label="名称"
|
label="名称"
|
||||||
placeholder="备注名称"
|
placeholder="备注名称"
|
||||||
value={editForm.name}
|
value={editForm.name}
|
||||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, name: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="起始端口"
|
label="起始端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={editForm.portRangeStart.toString()}
|
value={editForm.portRangeStart.toString()}
|
||||||
onChange={(e) => setEditForm({ ...editForm, portRangeStart: parseInt(e.target.value) || 0 })}
|
onChange={(e) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
portRangeStart: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="结束端口"
|
label="结束端口"
|
||||||
type="number"
|
type="number"
|
||||||
value={editForm.portRangeEnd.toString()}
|
value={editForm.portRangeEnd.toString()}
|
||||||
onChange={(e) => setEditForm({ ...editForm, portRangeEnd: parseInt(e.target.value) || 0 })}
|
onChange={(e) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
portRangeEnd: parseInt(e.target.value) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
|
description="0 表示不限流量"
|
||||||
label="流量上限 (GB)"
|
label="流量上限 (GB)"
|
||||||
type="number"
|
type="number"
|
||||||
description="0 表示不限流量"
|
|
||||||
value={editForm.maxBandwidth.toString()}
|
value={editForm.maxBandwidth.toString()}
|
||||||
onChange={(e) => setEditForm({ ...editForm, maxBandwidth: parseInt(e.target.value, 10) || 0 })}
|
onChange={(e) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
maxBandwidth: parseInt(e.target.value, 10) || 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="留空或清除表示永久有效"
|
||||||
label="过期时间"
|
label="过期时间"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
description="留空或清除表示永久有效"
|
value={
|
||||||
value={editForm.expiryTime > 0 ? new Date(editForm.expiryTime).toISOString().slice(0, 16) : ""}
|
editForm.expiryTime > 0
|
||||||
onChange={(e) => setEditForm({ ...editForm, expiryTime: e.target.value ? new Date(e.target.value).getTime() : 0 })}
|
? new Date(editForm.expiryTime).toISOString().slice(0, 16)
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditForm({
|
||||||
|
...editForm,
|
||||||
|
expiryTime: e.target.value
|
||||||
|
? new Date(e.target.value).getTime()
|
||||||
|
: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
||||||
label="允许的域名 (可选)"
|
label="允许的域名 (可选)"
|
||||||
placeholder="example.com, panel.test.com"
|
placeholder="example.com, panel.test.com"
|
||||||
description="限制使用此Token的来源面板域名,多个域名用逗号分隔,留空不限制"
|
|
||||||
value={editForm.allowedDomains}
|
value={editForm.allowedDomains}
|
||||||
onChange={(e) => setEditForm({ ...editForm, allowedDomains: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, allowedDomains: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
||||||
label="允许的API IP (可选)"
|
label="允许的API IP (可选)"
|
||||||
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
placeholder="203.0.113.10, 2001:db8::10, 198.51.100.0/24"
|
||||||
description="仅白名单IP可导入此分享,支持IPv4/IPv6/CIDR,多个用逗号分隔"
|
|
||||||
value={editForm.allowedIps}
|
value={editForm.allowedIps}
|
||||||
onChange={(e) => setEditForm({ ...editForm, allowedIps: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setEditForm({ ...editForm, allowedIps: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
<Button onPress={() => setEditShareOpen(false)}>取消</Button>
|
||||||
<Button color="primary" onPress={handleEditShare}>保存</Button>
|
<Button color="primary" onPress={handleEditShare}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -639,18 +804,24 @@ export default function PanelSharingPage() {
|
|||||||
label="远程面板地址"
|
label="远程面板地址"
|
||||||
placeholder="http://panel.example.com:8088"
|
placeholder="http://panel.example.com:8088"
|
||||||
value={importForm.remoteUrl}
|
value={importForm.remoteUrl}
|
||||||
onChange={(e) => setImportForm({ ...importForm, remoteUrl: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setImportForm({ ...importForm, remoteUrl: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
label="Token"
|
label="Token"
|
||||||
placeholder="Bearer Token"
|
placeholder="Bearer Token"
|
||||||
value={importForm.token}
|
value={importForm.token}
|
||||||
onChange={(e) => setImportForm({ ...importForm, token: e.target.value })}
|
onChange={(e) =>
|
||||||
|
setImportForm({ ...importForm, token: e.target.value })
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
<Button onPress={() => setImportNodeOpen(false)}>取消</Button>
|
||||||
<Button color="secondary" onPress={handleImportNode}>导入</Button>
|
<Button color="secondary" onPress={handleImportNode}>
|
||||||
|
导入
|
||||||
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user