mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 15:46:38 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21008ccb43 | |||
| 362d327bf9 | |||
| 9a650fcc8f | |||
| 804a5a29ea | |||
| 6189fe23f1 | |||
| 7ba90e8696 | |||
| 0eed74fe10 | |||
| 466cc65069 | |||
| 9c41410f17 | |||
| bc71c524e0 | |||
| f46b2b4d86 | |||
| 9f17d63cdc | |||
| 92f8ec47db | |||
| a97484cd9b | |||
| ee6bc8c50e | |||
| c94ab84ab9 | |||
| 84a03215f4 | |||
| def93749eb | |||
| 945a1c0dfc | |||
| d752e096a3 | |||
| 880a3b81b0 | |||
| 191aface2e | |||
| e121dadb90 | |||
| bafcfbde3a | |||
| 98c463c62b | |||
| daf34d0f6c | |||
| bb505d461d | |||
| 00be0ac31e | |||
| fc5624a190 | |||
| 42ae3457b5 | |||
| 088027da7b | |||
| d37adee5df | |||
| c147e52d72 | |||
| d483258eef | |||
| 79c28103d5 | |||
| f36bf1437c |
@@ -477,9 +477,14 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
forwardPortRows, err := h.repo.ListActiveForwardPortsForNode(nodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
usedSet := make(map[int]struct{})
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows))
|
||||
bindings := make([]remoteUsageBindingItem, 0, len(bindingRows)+len(forwardPortRows))
|
||||
for _, b := range bindingRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: b.ID,
|
||||
@@ -496,6 +501,29 @@ func (h *Handler) federationRemoteUsageList(w http.ResponseWriter, r *http.Reque
|
||||
usedSet[b.AllocatedPort] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, fp := range forwardPortRows {
|
||||
bindings = append(bindings, remoteUsageBindingItem{
|
||||
BindingID: -fp.ForwardID,
|
||||
TunnelID: fp.TunnelID,
|
||||
TunnelName: fp.TunnelName,
|
||||
ChainType: 1,
|
||||
HopInx: 0,
|
||||
AllocatedPort: fp.Port,
|
||||
ResourceKey: fmt.Sprintf("forward:%d", fp.ForwardID),
|
||||
RemoteBindingID: "",
|
||||
UpdatedTime: fp.UpdatedTime,
|
||||
})
|
||||
if fp.Port > 0 {
|
||||
usedSet[fp.Port] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(bindings, func(i, j int) bool {
|
||||
if bindings[i].AllocatedPort == bindings[j].AllocatedPort {
|
||||
return bindings[i].BindingID < bindings[j].BindingID
|
||||
}
|
||||
return bindings[i].AllocatedPort < bindings[j].AllocatedPort
|
||||
})
|
||||
|
||||
usedPorts := make([]int, 0, len(usedSet))
|
||||
for port := range usedSet {
|
||||
@@ -766,6 +794,37 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
usedPorts, err := h.repo.ListUsedPortsOnNode(share.NodeID)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
for _, port := range usedPorts {
|
||||
if port == req.RemotePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
runtimeOnPort, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if runtimeOnPort != nil {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
existsOnNodePort, err := h.repo.ExistsActivePeerShareRuntimeOnNodePort(share.NodeID, req.RemotePort)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if existsOnNodePort {
|
||||
response.WriteJSON(w, response.Err(403, "Port already in use"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
tunnelID, err := h.repo.CreateFederationTunnel(
|
||||
fmt.Sprintf("Share-%d-Port-%d", share.ID, req.RemotePort),
|
||||
@@ -780,6 +839,30 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
runtime := &repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("federation-forward-%d-%d-%d", share.ID, tunnelID, req.RemotePort),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: "",
|
||||
Protocol: defaultString(req.Protocol, "tcp"),
|
||||
Strategy: "fifo",
|
||||
Port: req.RemotePort,
|
||||
Target: strings.TrimSpace(req.Target),
|
||||
Applied: 0,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}
|
||||
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||
_ = h.deleteTunnelByID(tunnelID)
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
h.wsServer.SendCommand(share.NodeID, "reload", nil, time.Second*5)
|
||||
|
||||
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||
@@ -1211,9 +1294,182 @@ func (h *Handler) federationRuntimeCommand(w http.ResponseWriter, r *http.Reques
|
||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(cmd, "addservice") || strings.EqualFold(cmd, "updateservice") {
|
||||
h.bindPeerShareForwardRuntimeServices(share, req.Data)
|
||||
} else if strings.EqualFold(cmd, "deleteservice") {
|
||||
h.releasePeerShareForwardRuntimeServices(share, req.Data)
|
||||
}
|
||||
response.WriteJSON(w, response.OK(res))
|
||||
}
|
||||
|
||||
type federationForwardServiceBinding struct {
|
||||
Name string
|
||||
Port int
|
||||
}
|
||||
|
||||
func extractFederationServiceEntries(data interface{}) []map[string]interface{} {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(data); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if entries := asMapSlice(dataMap["services"]); len(entries) > 0 {
|
||||
return entries
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceBindings(data interface{}) []federationForwardServiceBinding {
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
bindings := make([]federationForwardServiceBinding, 0, len(serviceList))
|
||||
for _, svcMap := range serviceList {
|
||||
name := normalizeForwardRuntimeServiceName(asString(svcMap["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
continue
|
||||
}
|
||||
addr := strings.TrimSpace(asString(svcMap["addr"]))
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
continue
|
||||
}
|
||||
bindings = append(bindings, federationForwardServiceBinding{Name: name, Port: port})
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func parseFederationForwardServiceNamesForRelease(data interface{}) []string {
|
||||
names := make(map[string]struct{})
|
||||
appendName := func(raw string) {
|
||||
name := normalizeForwardRuntimeServiceName(raw)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if _, _, _, ok := parseFlowServiceIDs(name); !ok {
|
||||
return
|
||||
}
|
||||
names[name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, svcMap := range extractFederationServiceEntries(data) {
|
||||
appendName(asString(svcMap["name"]))
|
||||
}
|
||||
|
||||
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||
for _, item := range asAnySlice(dataMap["services"]) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range asAnySlice(data) {
|
||||
appendName(asString(item))
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) bindPeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
bindings := parseFederationForwardServiceBindings(data)
|
||||
if len(bindings) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, binding := range bindings {
|
||||
runtime, err := h.repo.GetActiveForwardPeerShareRuntimeByPort(share.ID, binding.Port)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime, err = h.repo.GetActiveForwardPeerShareRuntimeByServiceName(share.ID, binding.Name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
_ = h.repo.CreatePeerShareRuntime(&repo.PeerShareRuntime{
|
||||
ShareID: share.ID,
|
||||
NodeID: share.NodeID,
|
||||
ReservationID: randomToken(24),
|
||||
ResourceKey: fmt.Sprintf("forward-runtime:%d:%s:%d:%s", share.ID, binding.Name, binding.Port, randomToken(8)),
|
||||
BindingID: "",
|
||||
Role: "forward",
|
||||
ChainName: "",
|
||||
ServiceName: binding.Name,
|
||||
Protocol: "tcp",
|
||||
Strategy: "fifo",
|
||||
Port: binding.Port,
|
||||
Target: "",
|
||||
Applied: 1,
|
||||
Status: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if runtime.ServiceName == binding.Name && runtime.Applied == 1 && runtime.Port == binding.Port && runtime.Status == 1 {
|
||||
continue
|
||||
}
|
||||
runtime.ServiceName = binding.Name
|
||||
runtime.Port = binding.Port
|
||||
runtime.Applied = 1
|
||||
runtime.Status = 1
|
||||
runtime.UpdatedTime = now
|
||||
if strings.TrimSpace(runtime.Protocol) == "" {
|
||||
runtime.Protocol = "tcp"
|
||||
}
|
||||
if strings.TrimSpace(runtime.Strategy) == "" {
|
||||
runtime.Strategy = "fifo"
|
||||
}
|
||||
_ = h.repo.UpdatePeerShareRuntime(runtime)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) releasePeerShareForwardRuntimeServices(share *repo.PeerShare, data interface{}) {
|
||||
if h == nil || h.repo == nil || share == nil {
|
||||
return
|
||||
}
|
||||
names := parseFederationForwardServiceNamesForRelease(data)
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
for _, name := range names {
|
||||
_ = h.repo.MarkForwardPeerShareRuntimeReleasedByServiceName(share.ID, name, now)
|
||||
}
|
||||
}
|
||||
|
||||
func isFederationRuntimeCommandAllowed(commandType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(commandType)) {
|
||||
case "addservice", "updateservice", "deleteservice", "pauseservice", "resumeservice", "addchains", "deletechains", "addlimiters", "deletelimiters", "tcpping", "reload":
|
||||
@@ -1236,36 +1492,26 @@ func validateFederationCommandPorts(share *repo.PeerShare, data interface{}) err
|
||||
if share == nil || (share.PortRangeStart <= 0 && share.PortRangeEnd <= 0) {
|
||||
return nil
|
||||
}
|
||||
dataMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
|
||||
serviceList := extractFederationServiceEntries(data)
|
||||
if len(serviceList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if services, ok := dataMap["services"]; ok {
|
||||
serviceList, ok := services.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid services format")
|
||||
for _, svcMap := range serviceList {
|
||||
addr := asString(svcMap["addr"])
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
for _, svc := range serviceList {
|
||||
svcMap, ok := svc.(map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid service entry format")
|
||||
}
|
||||
addr, ok := svcMap["addr"].(string)
|
||||
if !ok || addr == "" {
|
||||
continue
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid service address: %s", addr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil || port <= 0 {
|
||||
return fmt.Errorf("invalid port in service address: %s", addr)
|
||||
}
|
||||
if port < share.PortRangeStart || port > share.PortRangeEnd {
|
||||
return fmt.Errorf("port %d out of allowed range %d-%d", port, share.PortRangeStart, share.PortRangeEnd)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -414,6 +414,445 @@ func TestFederationShareResetFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateCreatesPeerShareRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-forward-node", "federation-forward-secret", "10.90.80.70", "10.90.80.70", "", "24000-24020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-forward-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-forward-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24000,
|
||||
PortRangeEnd: 24020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("federation-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(federationTunnelRequest{
|
||||
Protocol: "tcp",
|
||||
RemotePort: 24001,
|
||||
Target: "1.1.1.1:443",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+share.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
h.federationTunnelCreate(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
runtimeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 24001)
|
||||
if runtimeCount != 1 {
|
||||
t.Fatalf("expected 1 runtime row for new federation forward tunnel, got %d", runtimeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationTunnelCreateRejectsOccupiedPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-check-node", "federation-port-check-secret", "10.91.80.70", "10.91.80.70", "", "24100-24120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "federation-port-check-node")
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "federation-port-check-share",
|
||||
NodeID: nodeID,
|
||||
Token: "federation-port-check-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 24100,
|
||||
PortRangeEnd: 24120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
|
||||
create := func() response.R {
|
||||
body, err := json.Marshal(federationTunnelRequest{Protocol: "tcp", RemotePort: 24101, Target: "1.1.1.1:443"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/tunnel/create", bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer federation-port-check-token")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
h.federationTunnelCreate(res, req)
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
first := create()
|
||||
if first.Code != 0 {
|
||||
t.Fatalf("expected first create success, got %d (%s)", first.Code, first.Msg)
|
||||
}
|
||||
|
||||
second := create()
|
||||
if second.Code != 403 {
|
||||
t.Fatalf("expected second create to be rejected with 403, got %d (%s)", second.Code, second.Msg)
|
||||
}
|
||||
if second.Msg != "Port already in use" {
|
||||
t.Fatalf("expected occupied port message, got %q", second.Msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTunnelReleasesFederationForwardRuntimeByPort(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "delete-forward-share",
|
||||
NodeID: 1,
|
||||
Token: "delete-forward-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 25000,
|
||||
PortRangeEnd: 25020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("delete-forward-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 25001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, tunnelName, 1.0, 1, "tcp", 1, now, now, 1, nil, 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "del-r1", "del-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 25001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := h.deleteTunnelByID(1); err != nil {
|
||||
t.Fatalf("delete tunnel: %v", err)
|
||||
}
|
||||
|
||||
activeCount := mustQueryInt(t, r, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND port = ? AND status = 1`, share.ID, 25001)
|
||||
if activeCount != 0 {
|
||||
t.Fatalf("expected runtime released after tunnel delete, active rows=%d", activeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesOnlyBindsForwardRole(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-forward-role-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-forward-role-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26000,
|
||||
PortRangeEnd: 26020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-forward-role-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-r1", "bind-rk1", "", "forward", "", "", "tcp", "fifo", 26001, "", 0, 1, now, now,
|
||||
2, share.ID, share.NodeID, "bind-r2", "bind-rk2", "", "middle", "", "", "tcp", "round", 26002, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtimes: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "77_2_10_tcp", "addr": "[::]:26001"},
|
||||
map[string]interface{}{"name": "88_2_10_tcp", "addr": "[::]:26002"},
|
||||
},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
middleServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 2`).Scan(&middleServiceName).Error; err != nil {
|
||||
t.Fatalf("load middle runtime service name: %v", err)
|
||||
}
|
||||
|
||||
if forwardServiceName != "77_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound, got %q", forwardServiceName)
|
||||
}
|
||||
if middleServiceName != "" {
|
||||
t.Fatalf("expected non-forward runtime unchanged, got %q", middleServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-array-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-array-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26100,
|
||||
PortRangeEnd: 26120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-array-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
1, share.ID, share.NodeID, "bind-array-r1", "bind-array-rk1", "", "forward", "", "", "tcp", "fifo", 26101, "", 0, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, []interface{}{
|
||||
map[string]interface{}{"name": "99_2_10_tcp", "addr": "[::]:26101"},
|
||||
map[string]interface{}{"name": "99_2_10_udp", "addr": "[::]:26101"},
|
||||
})
|
||||
|
||||
forwardServiceName := ""
|
||||
if err := r.DB().Raw(`SELECT service_name FROM peer_share_runtime WHERE id = 1`).Scan(&forwardServiceName).Error; err != nil {
|
||||
t.Fatalf("load forward runtime service name: %v", err)
|
||||
}
|
||||
if forwardServiceName != "99_2_10" {
|
||||
t.Fatalf("expected forward runtime service name bound from top-level array, got %q", forwardServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindPeerShareForwardRuntimeServicesCreatesRuntimeWhenMissing(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-bind-create-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "bind-create-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "bind-create-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26300,
|
||||
PortRangeEnd: 26320,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("bind-create-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
h.bindPeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{
|
||||
map[string]interface{}{"name": "55_2_10_tcp", "addr": "[::]:26301"},
|
||||
},
|
||||
})
|
||||
|
||||
var count int64
|
||||
if err := r.DB().Raw(`SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND role = ? AND status = 1`, share.ID, "forward").Scan(&count).Error; err != nil {
|
||||
t.Fatalf("query runtime count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("expected 1 active forward runtime row, got %d", count)
|
||||
}
|
||||
|
||||
var serviceName string
|
||||
var port int
|
||||
var applied int
|
||||
if err := r.DB().Raw(`SELECT service_name, port, applied FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&serviceName, &port, &applied); err != nil {
|
||||
t.Fatalf("query created runtime: %v", err)
|
||||
}
|
||||
if serviceName != "55_2_10" {
|
||||
t.Fatalf("expected service_name=55_2_10, got %q", serviceName)
|
||||
}
|
||||
if port != 26301 {
|
||||
t.Fatalf("expected port=26301, got %d", port)
|
||||
}
|
||||
if applied != 1 {
|
||||
t.Fatalf("expected applied=1, got %d", applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleasePeerShareForwardRuntimeServicesMarksRuntimeReleased(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-release-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "release-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "release-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 26400,
|
||||
PortRangeEnd: 26420,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("release-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "release-r1", "release-rk1", "", "forward", "", "77_2_10", "tcp", "fifo", 26401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert runtime: %v", err)
|
||||
}
|
||||
|
||||
h.releasePeerShareForwardRuntimeServices(share, map[string]interface{}{
|
||||
"services": []interface{}{"77_2_10_tcp"},
|
||||
})
|
||||
|
||||
var status int
|
||||
var applied int
|
||||
var serviceName string
|
||||
if err := r.DB().Raw(`SELECT status, applied, service_name FROM peer_share_runtime WHERE share_id = ? AND role = ? ORDER BY id DESC LIMIT 1`, share.ID, "forward").Row().Scan(&status, &applied, &serviceName); err != nil {
|
||||
t.Fatalf("query released runtime: %v", err)
|
||||
}
|
||||
if status != 0 {
|
||||
t.Fatalf("expected status=0 after release, got %d", status)
|
||||
}
|
||||
if applied != 0 {
|
||||
t.Fatalf("expected applied=0 after release, got %d", applied)
|
||||
}
|
||||
if serviceName != "" {
|
||||
t.Fatalf("expected service_name cleared after release, got %q", serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateFederationCommandPortsAcceptsTopLevelServiceArray(t *testing.T) {
|
||||
share := &repo.PeerShare{
|
||||
PortRangeStart: 26200,
|
||||
PortRangeEnd: 26210,
|
||||
}
|
||||
err := validateFederationCommandPorts(share, []interface{}{
|
||||
map[string]interface{}{"name": "11_2_10_tcp", "addr": "[::]:26201"},
|
||||
map[string]interface{}{"name": "11_2_10_udp", "addr": "[::]:26201"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected top-level service array to pass port validation, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageList(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
@@ -502,6 +941,110 @@ func TestFederationRemoteUsageList(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationRemoteUsageListIncludesForwardPorts(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-usage.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = r.Close() })
|
||||
|
||||
h := New(r, "test-jwt-secret")
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-remote-node", "forward-usage-secret", "10.60.70.80", "10.60.70.80", "", "33000-33010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", `{"shareId":99,"maxBandwidth":0,"currentFlow":0,"portRangeStart":33000,"portRangeEnd":33010}`).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
|
||||
var nodeID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM node WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-remote-node").Row().Scan(&nodeID); err != nil {
|
||||
t.Fatalf("query node id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "forward-usage-tunnel", 1, "tls", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
var tunnelID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-tunnel").Row().Scan(&tunnelID); err != nil {
|
||||
t.Fatalf("query tunnel id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "tester", "forward-usage-item", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
var forwardID int64
|
||||
if err := r.DB().Raw(`SELECT id FROM forward WHERE name = ? ORDER BY id DESC LIMIT 1`, "forward-usage-item").Row().Scan(&forwardID); err != nil {
|
||||
t.Fatalf("query forward id: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 33001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
res := httptest.NewRecorder()
|
||||
h.federationRemoteUsageList(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, res.Code)
|
||||
}
|
||||
|
||||
var payload response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if payload.Code != 0 {
|
||||
t.Fatalf("expected response code 0, got %d (%s)", payload.Code, payload.Msg)
|
||||
}
|
||||
|
||||
rows, ok := payload.Data.([]interface{})
|
||||
if !ok || len(rows) == 0 {
|
||||
t.Fatalf("expected non-empty usage list, got %T", payload.Data)
|
||||
}
|
||||
|
||||
first, ok := rows[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usage row map, got %T", rows[0])
|
||||
}
|
||||
|
||||
usedPortsRaw, ok := first["usedPorts"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected usedPorts array, got %T", first["usedPorts"])
|
||||
}
|
||||
if len(usedPortsRaw) != 1 || int(usedPortsRaw[0].(float64)) != 33001 {
|
||||
t.Fatalf("expected usedPorts [33001], got %v", usedPortsRaw)
|
||||
}
|
||||
|
||||
bindingsRaw, ok := first["bindings"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected bindings array, got %T", first["bindings"])
|
||||
}
|
||||
if len(bindingsRaw) != 1 {
|
||||
t.Fatalf("expected 1 binding row from forward usage, got %d", len(bindingsRaw))
|
||||
}
|
||||
|
||||
binding, ok := bindingsRaw[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected binding row object, got %T", bindingsRaw[0])
|
||||
}
|
||||
if int(binding["allocatedPort"].(float64)) != 33001 {
|
||||
t.Fatalf("expected allocatedPort=33001, got %v", binding["allocatedPort"])
|
||||
}
|
||||
if int(binding["chainType"].(float64)) != 1 {
|
||||
t.Fatalf("expected chainType=1 for forward usage row, got %v", binding["chainType"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthPeerAllowedIPs(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||
if err != nil {
|
||||
|
||||
@@ -2,9 +2,12 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
const bytesPerGB int64 = 1024 * 1024 * 1024
|
||||
@@ -30,7 +33,7 @@ type namedConfigItem struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (h *Handler) processFlowItem(item flowItem) {
|
||||
func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
|
||||
serviceName := strings.TrimSpace(item.N)
|
||||
if serviceName == "" || serviceName == "web_api" {
|
||||
return
|
||||
@@ -40,6 +43,7 @@ func (h *Handler) processFlowItem(item flowItem) {
|
||||
if ok {
|
||||
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
|
||||
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
|
||||
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
|
||||
|
||||
if userTunnelID > 0 {
|
||||
h.enforceFlowPolicies(userID, userTunnelID)
|
||||
@@ -87,6 +91,45 @@ func parsePeerShareRuntimeServiceID(serviceName string) (int64, bool) {
|
||||
return runtimeID, true
|
||||
}
|
||||
|
||||
func parsePeerShareInfoFromFederationTunnelName(tunnelName string) (int64, int, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
portValue := strings.TrimSpace(raw[idx+len("-Port-"):])
|
||||
port, err := strconv.Atoi(portValue)
|
||||
if err != nil || port <= 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return shareID, port, true
|
||||
}
|
||||
|
||||
func parsePeerShareIDFromFederationTunnelName(tunnelName string) (int64, bool) {
|
||||
tunnelName = strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(tunnelName, "Share-") {
|
||||
return 0, false
|
||||
}
|
||||
raw := strings.TrimPrefix(tunnelName, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
shareID, err := strconv.ParseInt(raw[:idx], 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return shareID, true
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
if h == nil || h.repo == nil || runtimeID <= 0 {
|
||||
return
|
||||
@@ -113,6 +156,121 @@ func (h *Handler) processPeerShareFlow(runtimeID int64, item flowItem) {
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowFromForward(forwardID int64, nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || forwardID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
forward, err := h.getForwardRecord(forwardID)
|
||||
if err != nil || forward == nil {
|
||||
// Forward not found in local database - might be a federation port-forward
|
||||
// Try to find by service name in peer_share_runtime
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
tunnelName, err := h.repo.GetTunnelName(forward.TunnelID)
|
||||
if err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
shareID, ok := parsePeerShareIDFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.AddPeerShareCurrentFlow(shareID, delta); err != nil {
|
||||
h.processPeerShareFlowByServiceName(nodeID, serviceName, item)
|
||||
return
|
||||
}
|
||||
|
||||
share, err := h.repo.GetPeerShare(shareID)
|
||||
if err != nil || share == nil {
|
||||
return
|
||||
}
|
||||
if !isPeerShareFlowExceeded(share) {
|
||||
return
|
||||
}
|
||||
h.enforcePeerShareFlowLimit(share.ID)
|
||||
}
|
||||
|
||||
func normalizeForwardRuntimeServiceName(serviceName string) string {
|
||||
name := strings.TrimSpace(serviceName)
|
||||
if strings.HasSuffix(name, "_tcp") {
|
||||
return strings.TrimSuffix(name, "_tcp")
|
||||
}
|
||||
if strings.HasSuffix(name, "_udp") {
|
||||
return strings.TrimSuffix(name, "_udp")
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (h *Handler) processPeerShareFlowByServiceName(nodeID int64, serviceName string, item flowItem) {
|
||||
if h == nil || h.repo == nil || strings.TrimSpace(serviceName) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
delta := item.D + item.U
|
||||
if delta <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
normalized := normalizeForwardRuntimeServiceName(serviceName)
|
||||
var runtimes []model.PeerShareRuntime
|
||||
var err error
|
||||
|
||||
// Try node-scoped query first if nodeID is valid
|
||||
if nodeID > 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID, serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global query if node-scoped query returned nothing or nodeID is invalid
|
||||
if len(runtimes) == 0 {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(normalized)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(runtimes) == 0 && normalized != serviceName {
|
||||
runtimes, err = h.repo.ListActiveForwardPeerShareRuntimesByServiceName(serviceName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(runtimes) != 1 {
|
||||
if len(runtimes) > 1 {
|
||||
log.Printf("WARN: ambiguous peer share runtime match for service=%s nodeID=%d count=%d", serviceName, nodeID, len(runtimes))
|
||||
}
|
||||
return
|
||||
}
|
||||
runtime := runtimes[0]
|
||||
|
||||
_ = h.repo.AddPeerShareCurrentFlow(runtime.ShareID, delta)
|
||||
|
||||
matchedShare, err := h.repo.GetPeerShare(runtime.ShareID)
|
||||
if err != nil || matchedShare == nil {
|
||||
return
|
||||
}
|
||||
if isPeerShareFlowExceeded(matchedShare) {
|
||||
h.enforcePeerShareFlowLimit(matchedShare.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) enforcePeerShareFlowLimit(shareID int64) {
|
||||
if h == nil || h.repo == nil || shareID <= 0 {
|
||||
return
|
||||
@@ -271,15 +429,46 @@ func (h *Handler) cleanNodeConfigs(nodeID int64, rawConfig string) {
|
||||
}
|
||||
|
||||
func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem) {
|
||||
runtimeServiceNames, err := h.repo.ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
minUpdatedTime := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||
hasUnboundForwardPeerRuntime, err := h.repo.HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID, minUpdatedTime)
|
||||
if err != nil {
|
||||
hasUnboundForwardPeerRuntime = false
|
||||
}
|
||||
runtimeServiceSet := make(map[string]struct{}, len(runtimeServiceNames))
|
||||
for _, serviceName := range runtimeServiceNames {
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
continue
|
||||
}
|
||||
runtimeServiceSet[serviceName] = struct{}{}
|
||||
}
|
||||
|
||||
for _, item := range services {
|
||||
name := strings.TrimSpace(item.Name)
|
||||
if name == "" || name == "web_api" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "fed_svc_") {
|
||||
continue
|
||||
}
|
||||
normalizedName := normalizeForwardRuntimeServiceName(name)
|
||||
if _, ok := runtimeServiceSet[normalizedName]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := runtimeServiceSet[name]; ok {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(name, "_")
|
||||
if len(parts) >= 3 {
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err == nil && forwardID > 0 && !h.forwardExists(forwardID) {
|
||||
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name, parts[0] + "_" + parts[1] + "_" + parts[2], parts[0] + "_" + parts[1] + "_" + parts[2] + "_tcp", parts[0] + "_" + parts[1] + "_" + parts[2] + "_udp"}}, false, true)
|
||||
continue
|
||||
@@ -299,6 +488,9 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
|
||||
continue
|
||||
}
|
||||
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
|
||||
continue
|
||||
}
|
||||
if err != nil || forwardID <= 0 || h.forwardExists(forwardID) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -43,7 +44,7 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
h.processFlowItem(1, flowItem{N: "fed_svc_17", U: 1200, D: 900})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
@@ -61,3 +62,345 @@ func TestProcessFlowItemTracksPeerShareFlowAndEnforcesLimit(t *testing.T) {
|
||||
t.Fatalf("expected runtime status=0 after limit enforcement, got %d", runtime.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowForFederationPortForward(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-share-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 30000,
|
||||
PortRangeEnd: 30010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-share-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.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(2, 'u2', 'x', 1, ?, 99999, 0, 0, 1, 1, ?, ?, 1)
|
||||
`, now+24*60*60*1000, now, now).Error; err != nil {
|
||||
t.Fatalf("insert user: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := "Share-" + strconv.FormatInt(share.ID, 10) + "-Port-30001"
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, ?, 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, tunnelName, now, now).Error; err != nil {
|
||||
t.Fatalf("insert tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(10, 2, 1, NULL, 1, 99999, 0, 0, 1, ?, 1)
|
||||
`, now+24*60*60*1000).Error; err != nil {
|
||||
t.Fatalf("insert user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'u2', 'f20', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemTracksPeerShareFlowByForwardServiceName(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "forward-service-share",
|
||||
NodeID: 1,
|
||||
Token: "forward-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31000,
|
||||
PortRangeEnd: 31010,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("forward-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "svc-r1", "svc-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31001, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemFallsBackToServiceNameWhenForwardIDCollidesAcrossPanels(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-collision.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "collision-share",
|
||||
NodeID: 1,
|
||||
Token: "collision-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31400,
|
||||
PortRangeEnd: 31410,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("collision-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "collision-r1", "collision-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31401, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(2, 'local-tunnel-with-colliding-forward-id', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 1, 'local-user', 'local-f20', 2, '8.8.8.8:53', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
`, now, now).Error; err != nil {
|
||||
t.Fatalf("insert local forward: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "20_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedShare, err := r.GetPeerShare(share.ID)
|
||||
if err != nil || updatedShare == nil {
|
||||
t.Fatalf("reload share: %v", err)
|
||||
}
|
||||
if updatedShare.CurrentFlow != 200 {
|
||||
t.Fatalf("expected current_flow=200, got %d", updatedShare.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessFlowItemSkipsPeerShareFlowWhenServiceNameIsAmbiguous(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-forward-ambiguous.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-a",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-a",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31100,
|
||||
PortRangeEnd: 31110,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share A: %v", err)
|
||||
}
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "ambiguous-share-b",
|
||||
NodeID: 1,
|
||||
Token: "ambiguous-token-b",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31200,
|
||||
PortRangeEnd: 31210,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share B: %v", err)
|
||||
}
|
||||
shareA, _ := r.GetPeerShareByToken("ambiguous-token-a")
|
||||
shareB, _ := r.GetPeerShareByToken("ambiguous-token-b")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?),
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
shareA.ID, 1, "amb-r1", "amb-rk1", "", "forward", "", "99_2_10", "tcp", "fifo", 31101, "", 1, 1, now, now,
|
||||
shareB.ID, 1, "amb-r2", "amb-rk2", "", "forward", "", "99_2_10", "tcp", "fifo", 31201, "", 1, 1, now, now,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert ambiguous runtimes: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
h.processFlowItem(1, flowItem{N: "99_2_10_tcp", U: 120, D: 80})
|
||||
|
||||
updatedA, _ := r.GetPeerShare(shareA.ID)
|
||||
updatedB, _ := r.GetPeerShare(shareB.ID)
|
||||
if updatedA.CurrentFlow != 0 || updatedB.CurrentFlow != 0 {
|
||||
t.Fatalf("expected ambiguous service flow to be skipped, got shareA=%d shareB=%d", updatedA.CurrentFlow, updatedB.CurrentFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsActiveSharedForwardRuntimeServices(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-runtime.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-runtime-share",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-runtime-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31300,
|
||||
PortRangeEnd: 31310,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-runtime-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-r1", "cleanup-rk1", "", "forward", "", "20_2_10", "tcp", "fifo", 31301, "", 1, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip active shared runtime service; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsFederationServicePrefix(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-fed-svc.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip fed_svc_ service names; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(1, []namedConfigItem{{Name: "fed_svc_999_tcp"}})
|
||||
}
|
||||
|
||||
func TestCleanOrphanedServicesSkipsForwardPatternWhenNodeHasActivePeerShareForwardRuntime(t *testing.T) {
|
||||
r, err := repo.Open(filepath.Join(t.TempDir(), "panel-cleanup-forward-runtime-empty-service.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("open repo: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "cleanup-forward-runtime-empty-service",
|
||||
NodeID: 1,
|
||||
Token: "cleanup-forward-runtime-empty-service-token",
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 0,
|
||||
PortRangeStart: 31420,
|
||||
PortRangeEnd: 31430,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create peer share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken("cleanup-forward-runtime-empty-service-token")
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load peer share: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, share.ID, share.NodeID, "cleanup-forward-empty-r1", "cleanup-forward-empty-rk1", "", "forward", "", "", "tcp", "fifo", 31421, "", 0, 1, now, now).Error; err != nil {
|
||||
t.Fatalf("insert peer_share_runtime with empty service name: %v", err)
|
||||
}
|
||||
|
||||
h := &Handler{repo: r}
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
t.Fatalf("cleanOrphanedServices should skip forward-pattern services when active peer-share forward runtime exists; got panic: %v", rec)
|
||||
}
|
||||
}()
|
||||
|
||||
h.cleanOrphanedServices(share.NodeID, []namedConfigItem{{Name: "20_2_10_tcp"}})
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
if captchaEnabled {
|
||||
if captchaEnabled && !h.apiClientCaptchaBypassEnabled(r) {
|
||||
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||
if captchaID == "" {
|
||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||
@@ -704,7 +704,8 @@ func (h *Handler) flowConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
secret := r.URL.Query().Get("secret")
|
||||
if ok, _ := h.repo.NodeExistsBySecret(secret); !ok {
|
||||
node, _ := h.repo.GetNodeBySecret(secret)
|
||||
if node == nil {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
@@ -715,7 +716,7 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) {
|
||||
var items []flowItem
|
||||
if json.Unmarshal([]byte(raw), &items) == nil {
|
||||
for _, item := range items {
|
||||
h.processFlowItem(item)
|
||||
h.processFlowItem(node.ID, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,6 +995,20 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
||||
return strings.EqualFold(cfg.Value, "true"), nil
|
||||
}
|
||||
|
||||
func (h *Handler) apiClientCaptchaBypassEnabled(r *http.Request) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
client := strings.ToLower(strings.TrimSpace(r.Header.Get("X-FLVX-API-Client")))
|
||||
switch client {
|
||||
case "whmcs", "whmcs-module":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) markCaptchaToken(token string) {
|
||||
if h == nil {
|
||||
return
|
||||
|
||||
@@ -69,6 +69,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert expired user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.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(3, 'non_expiring_user', 'x', 1, 0, 100, 1000, 2000, 15, 1, ?, ?, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(id, name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(1, 't1', 1.0, 1, 'tls', 1, ?, ?, 1, NULL, 0)
|
||||
@@ -83,6 +90,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert expired user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||
VALUES(11, 3, 1, NULL, 1, 1, 300, 400, 15, 0, 1)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring user_tunnel: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(20, 2, 'expired_user', 'f1', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||
@@ -90,6 +104,13 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(id, user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(21, 3, 'non_expiring_user', 'f2', 1, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 1)
|
||||
`, nowMs, nowMs).Error; err != nil {
|
||||
t.Fatalf("insert non-expiring forward: %v", err)
|
||||
}
|
||||
|
||||
h.runResetAndExpiryJob(now)
|
||||
|
||||
userIn, userOut, userStatus := mustQueryInt64Int64Int(t, r, `SELECT in_flow, out_flow, status FROM user WHERE id = 2`)
|
||||
@@ -106,4 +127,19 @@ func TestRunResetAndExpiryJobResetsFlowAndDisablesExpiredRecords(t *testing.T) {
|
||||
if forwardStatus != 0 {
|
||||
t.Fatalf("expected forward status=0 after expiry handling, got %d", forwardStatus)
|
||||
}
|
||||
|
||||
nonExpUserStatus := mustQueryInt(t, r, `SELECT status FROM user WHERE id = 3`)
|
||||
if nonExpUserStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user to remain enabled, got status=%d", nonExpUserStatus)
|
||||
}
|
||||
|
||||
nonExpTunnelStatus := mustQueryInt(t, r, `SELECT status FROM user_tunnel WHERE id = 11`)
|
||||
if nonExpTunnelStatus != 1 {
|
||||
t.Fatalf("expected non-expiring user_tunnel to remain enabled, got status=%d", nonExpTunnelStatus)
|
||||
}
|
||||
|
||||
nonExpForwardStatus := mustQueryInt(t, r, `SELECT status FROM forward WHERE id = 21`)
|
||||
if nonExpForwardStatus != 1 {
|
||||
t.Fatalf("expected non-expiring forward to remain enabled, got status=%d", nonExpForwardStatus)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2794,7 +2794,21 @@ func (h *Handler) deleteNodeByID(id int64) error {
|
||||
}
|
||||
|
||||
func (h *Handler) deleteTunnelByID(id int64) error {
|
||||
return h.repo.DeleteTunnelCascade(id)
|
||||
if h == nil || h.repo == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
|
||||
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||
if err := h.repo.DeleteTunnelCascade(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shareID, port, ok := parsePeerShareInfoFromFederationTunnelName(tunnelName)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return h.repo.MarkPeerShareRuntimeReleasedByPort(shareID, port, time.Now().UnixMilli())
|
||||
}
|
||||
|
||||
func (h *Handler) deleteForwardByID(id int64) error {
|
||||
|
||||
@@ -1270,6 +1270,176 @@ func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByServiceName(serviceName string) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("service_name = ? AND status = 1 AND role = ?", serviceName, "forward").
|
||||
Order("id ASC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimesByNodeAndServiceName(nodeID int64, serviceName string) ([]model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if serviceName == "" {
|
||||
return []model.PeerShareRuntime{}, nil
|
||||
}
|
||||
var items []model.PeerShareRuntime
|
||||
err := r.db.Where("node_id = ? AND service_name = ? AND status = 1 AND role = ?", nodeID, serviceName, "forward").
|
||||
Order("id ASC").
|
||||
Find(&items).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if items == nil {
|
||||
items = make([]model.PeerShareRuntime, 0)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
|
||||
func (r *Repository) ListActiveForwardPeerShareRuntimeServiceNamesByNode(nodeID int64) ([]string, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var names []string
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND service_name <> ''", nodeID, "forward").
|
||||
Pluck("service_name", &names).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names == nil {
|
||||
names = make([]string, 0)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
func (r *Repository) HasRecentUnboundForwardPeerShareRuntimeOnNode(nodeID int64, minUpdatedTime int64) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND status = 1 AND role = ? AND applied = 0 AND updated_time >= ? AND (service_name = '' OR service_name IS NULL)", nodeID, "forward", minUpdatedTime).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByPort(shareID int64, port int) (*model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var item model.PeerShareRuntime
|
||||
err := r.db.Where("share_id = ? AND port = ? AND status = 1 AND role = ?", shareID, port, "forward").First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) GetActiveForwardPeerShareRuntimeByServiceName(shareID int64, serviceName string) (*model.PeerShareRuntime, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if shareID <= 0 || serviceName == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var item model.PeerShareRuntime
|
||||
err := r.db.Where("share_id = ? AND service_name = ? AND status = 1 AND role = ?", shareID, serviceName, "forward").
|
||||
Order("id ASC").
|
||||
First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ExistsActivePeerShareRuntimeOnNodePort(nodeID int64, port int) (bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return false, errors.New("repository not initialized")
|
||||
}
|
||||
var count int64
|
||||
err := r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("node_id = ? AND port = ? AND status = 1", nodeID, port).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdatePeerShareRuntimeServiceName(id int64, serviceName string, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"service_name": serviceName,
|
||||
"applied": 1,
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) MarkPeerShareRuntimeReleasedByPort(shareID int64, port int, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if shareID <= 0 || port <= 0 {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).Where("share_id = ? AND port = ? AND status = 1", shareID, port).Updates(map[string]interface{}{
|
||||
"status": 0,
|
||||
"applied": 0,
|
||||
"service_name": "",
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) MarkForwardPeerShareRuntimeReleasedByServiceName(shareID int64, serviceName string, updatedTime int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
serviceName = strings.TrimSpace(serviceName)
|
||||
if shareID <= 0 || serviceName == "" {
|
||||
return nil
|
||||
}
|
||||
if updatedTime <= 0 {
|
||||
updatedTime = unixMilliNow()
|
||||
}
|
||||
return r.db.Model(&model.PeerShareRuntime{}).
|
||||
Where("share_id = ? AND status = 1 AND role = ? AND service_name = ?", shareID, "forward", serviceName).
|
||||
Updates(map[string]interface{}{
|
||||
"status": 0,
|
||||
"applied": 0,
|
||||
"service_name": "",
|
||||
"updated_time": updatedTime,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// ─── FederationTunnelBinding ─────────────────────────────────────────
|
||||
|
||||
func (r *Repository) UpsertFederationTunnelBinding(item *model.FederationTunnelBinding) error {
|
||||
@@ -2235,7 +2405,7 @@ func (r *Repository) ListExpiredActiveUserIDs(nowMs int64) ([]int64, error) {
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.User{}).
|
||||
Where("role_id != 0 AND status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).
|
||||
Where("role_id != 0 AND status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).
|
||||
Pluck("id", &ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -2255,7 +2425,7 @@ func (r *Repository) ListExpiredActiveUserTunnels(nowMs int64) ([]model.ExpiredU
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var uts []model.UserTunnel
|
||||
err := r.db.Where("status = 1 AND exp_time IS NOT NULL AND exp_time < ?", nowMs).Find(&uts).Error
|
||||
err := r.db.Where("status = 1 AND exp_time > 0 AND exp_time < ?", nowMs).Find(&uts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,6 +38,14 @@ type FederationBindingRow struct {
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
type ActiveForwardPortRow struct {
|
||||
ForwardID int64
|
||||
TunnelID int64
|
||||
TunnelName string
|
||||
Port int
|
||||
UpdatedTime int64
|
||||
}
|
||||
|
||||
// ListRemoteNodes returns all nodes with is_remote=1, ordered by id desc.
|
||||
func (r *Repository) ListRemoteNodes() ([]RemoteNodeRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@@ -87,6 +95,27 @@ func (r *Repository) ListActiveBindingsForNode(nodeID int64) ([]FederationBindin
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *Repository) ListActiveForwardPortsForNode(nodeID int64) ([]ActiveForwardPortRow, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var result []ActiveForwardPortRow
|
||||
err := r.db.Model(&model.ForwardPort{}).
|
||||
Select("forward_port.forward_id, forward.tunnel_id, COALESCE(tunnel.name, '') AS tunnel_name, forward_port.port, forward.updated_time").
|
||||
Joins("JOIN forward ON forward.id = forward_port.forward_id").
|
||||
Joins("LEFT JOIN tunnel ON tunnel.id = forward.tunnel_id").
|
||||
Where("forward_port.node_id = ? AND forward_port.port > 0", nodeID).
|
||||
Order("forward_port.port ASC, forward_port.id ASC").
|
||||
Find(&result).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result == nil {
|
||||
result = make([]ActiveForwardPortRow, 0)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetNodeBasicInfo returns the name, server_ip, and status for a given node.
|
||||
func (r *Repository) GetNodeBasicInfo(nodeID int64) (*NodeBasicInfo, error) {
|
||||
if r == nil || r.db == nil {
|
||||
|
||||
@@ -166,7 +166,7 @@ func TestFederationDualPanelMiddleExitAutoPortContract(t *testing.T) {
|
||||
|
||||
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 = ?`, entryShareID, 0)
|
||||
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, entryShareID, 1)
|
||||
}
|
||||
|
||||
func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
package contract_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-backend/internal/auth"
|
||||
"go-backend/internal/http/response"
|
||||
"go-backend/internal/store/repo"
|
||||
)
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContract(t *testing.T) {
|
||||
secret := "federation-forward-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-local-node", "flow-local-secret", "10.20.30.40", "10.20.30.40", "", "32000-32020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-local-node")
|
||||
|
||||
shareToken := "flow-linkage-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-linkage-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 1536,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32000,
|
||||
PortRangeEnd: 32020,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, 32001)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-share-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-linkage-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-linkage-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, 32001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
var targetForward map[string]interface{}
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
targetForward = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if targetForward == nil {
|
||||
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||
}
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
parsedShareID := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedShareID != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d (tunnelName=%q)", share.ID, parsedShareID, contractValueAsString(targetForward["tunnelName"]))
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[parsedShareID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", parsedShareID)
|
||||
}
|
||||
|
||||
count := forwardCountByShare[parsedShareID]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow := estimated
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after frontend-style merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != share.CurrentFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", share.CurrentFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractSplitShareFlowAcrossMultipleForwards(t *testing.T) {
|
||||
secret := "federation-forward-split-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "flow-split-local-node", "flow-split-local-secret", "10.21.31.41", "10.21.31.41", "", "32100-32120", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 0, "", "", "").Error; err != nil {
|
||||
t.Fatalf("insert local node: %v", err)
|
||||
}
|
||||
nodeID := mustLastInsertID(t, r, "flow-split-local-node")
|
||||
|
||||
shareToken := "flow-split-share-token"
|
||||
if err := r.CreatePeerShare(&repo.PeerShare{
|
||||
Name: "flow-split-share",
|
||||
NodeID: nodeID,
|
||||
Token: shareToken,
|
||||
MaxBandwidth: 0,
|
||||
CurrentFlow: 4097,
|
||||
ExpiryTime: 0,
|
||||
PortRangeStart: 32100,
|
||||
PortRangeEnd: 32120,
|
||||
IsActive: 1,
|
||||
CreatedTime: now,
|
||||
UpdatedTime: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("create share: %v", err)
|
||||
}
|
||||
share, err := r.GetPeerShareByToken(shareToken)
|
||||
if err != nil || share == nil {
|
||||
t.Fatalf("load share: %v", err)
|
||||
}
|
||||
|
||||
createShareForward := func(name string, port int) int64 {
|
||||
t.Helper()
|
||||
|
||||
tunnelName := fmt.Sprintf("Share-%d-Port-%d", share.ID, port)
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, tunnelName, 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert share tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-split-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", name, tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, name)
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeID, port).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
return forwardID
|
||||
}
|
||||
|
||||
forwardIDA := createShareForward("flow-split-forward-a", 32101)
|
||||
forwardIDB := createShareForward("flow-split-forward-b", 32102)
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) != 0 {
|
||||
t.Fatalf("expected no remote usage rows in local-only fixture, got %d", len(remoteUsageRows))
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[share.ID]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for share %d", share.ID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(m["tunnelName"]))
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
count := forwardCountByShare[share.ID]
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 forwards sharing share %d, got %d", share.ID, count)
|
||||
}
|
||||
|
||||
expectedEach := shareFlow / int64(count)
|
||||
if expectedEach < 1 {
|
||||
expectedEach = 1
|
||||
}
|
||||
|
||||
findForward := func(forwardID int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == forwardID {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", forwardID)
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, forwardID := range []int64{forwardIDA, forwardIDB} {
|
||||
forward := findForward(forwardID)
|
||||
sid := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if sid != share.ID {
|
||||
t.Fatalf("expected parsed shareID=%d, got %d for forward %d", share.ID, sid, forwardID)
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(forward["inFlow"]) + contractValueAsInt64(forward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0 for forward %d, got %d", forwardID, directFlow)
|
||||
}
|
||||
|
||||
displayFlow := int64(0)
|
||||
if directFlow > 0 {
|
||||
displayFlow = directFlow
|
||||
} else {
|
||||
shareFlowForForward := flowByShare[sid]
|
||||
if shareFlowForForward > 0 {
|
||||
cnt := forwardCountByShare[sid]
|
||||
if cnt <= 0 {
|
||||
cnt = 1
|
||||
}
|
||||
estimated := shareFlowForForward / int64(cnt)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
displayFlow = estimated
|
||||
}
|
||||
}
|
||||
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 for forward %d, got %d", forwardID, displayFlow)
|
||||
}
|
||||
if displayFlow != expectedEach {
|
||||
t.Fatalf("expected displayFlow=%d for forward %d, got %d", expectedEach, forwardID, displayFlow)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFederationForwardCardFlowLinkageContractResolvesShareByTunnelBindingWhenTunnelNameIsCustom(t *testing.T) {
|
||||
secret := "federation-forward-binding-flow-contract-jwt"
|
||||
router, r := setupContractRouter(t, secret)
|
||||
|
||||
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||
if err != nil {
|
||||
t.Fatalf("generate admin token: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
remoteShareID := int64(901)
|
||||
remoteShareFlow := int64(5000)
|
||||
|
||||
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
"flow-binding-remote-node", "flow-binding-remote-secret", "10.31.41.51", "10.31.41.51", "", "33000-33020", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0, 1, "", "", fmt.Sprintf(`{"shareId":%d,"maxBandwidth":0,"currentFlow":%d,"portRangeStart":33000,"portRangeEnd":33020}`, remoteShareID, remoteShareFlow),
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert remote node: %v", err)
|
||||
}
|
||||
remoteNodeID := mustLastInsertID(t, r, "flow-binding-remote-node")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO tunnel(name, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, "federation-port-forward-custom-name", 1, "tcp", 1, now, now, 1, "", 0).Error; err != nil {
|
||||
t.Fatalf("insert custom tunnel: %v", err)
|
||||
}
|
||||
tunnelID := mustLastInsertID(t, r, "flow-binding-custom-tunnel")
|
||||
|
||||
if err := r.DB().Exec(`
|
||||
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, 1, "admin_user", "flow-binding-forward", tunnelID, "1.1.1.1:443", "fifo", 0, 0, now, now, 1, 0).Error; err != nil {
|
||||
t.Fatalf("insert forward: %v", err)
|
||||
}
|
||||
forwardID := mustLastInsertID(t, r, "flow-binding-forward")
|
||||
|
||||
if err := r.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, remoteNodeID, 33001).Error; err != nil {
|
||||
t.Fatalf("insert forward_port: %v", err)
|
||||
}
|
||||
|
||||
forwardOut := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||
if forwardOut.Code != 0 {
|
||||
t.Fatalf("forward list failed: code=%d msg=%q", forwardOut.Code, forwardOut.Msg)
|
||||
}
|
||||
forwardRows := mustContractSlice(t, forwardOut.Data, "forward list data")
|
||||
|
||||
shareOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/list", nil)
|
||||
if shareOut.Code != 0 {
|
||||
t.Fatalf("share list failed: code=%d msg=%q", shareOut.Code, shareOut.Msg)
|
||||
}
|
||||
localShareRows := mustContractSlice(t, shareOut.Data, "share list data")
|
||||
|
||||
remoteUsageOut := requestContractEnvelope(t, router, adminToken, "/api/v1/federation/share/remote-usage/list", nil)
|
||||
if remoteUsageOut.Code != 0 {
|
||||
t.Fatalf("remote usage list failed: code=%d msg=%q", remoteUsageOut.Code, remoteUsageOut.Msg)
|
||||
}
|
||||
remoteUsageRows := mustContractSlice(t, remoteUsageOut.Data, "remote usage data")
|
||||
if len(remoteUsageRows) == 0 {
|
||||
t.Fatalf("expected non-empty remote usage rows")
|
||||
}
|
||||
|
||||
findForward := func(id int64) map[string]interface{} {
|
||||
t.Helper()
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if contractValueAsInt64(m["id"]) == id {
|
||||
return m
|
||||
}
|
||||
}
|
||||
t.Fatalf("forward %d not found in /forward/list response", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
flowByShare := make(map[int64]int64)
|
||||
shareIDsByTunnel := make(map[int64]map[int64]struct{})
|
||||
|
||||
for _, row := range remoteUsageRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["shareId"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
|
||||
bindings, _ := m["bindings"].([]interface{})
|
||||
for _, bindingRaw := range bindings {
|
||||
binding, ok := bindingRaw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
tunnelIDVal := contractValueAsInt64(binding["tunnelId"])
|
||||
chainType := contractValueAsInt64(binding["chainType"])
|
||||
if shareID <= 0 || tunnelIDVal <= 0 {
|
||||
continue
|
||||
}
|
||||
if chainType != 1 {
|
||||
continue
|
||||
}
|
||||
setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]
|
||||
if !ok {
|
||||
setByTunnel = make(map[int64]struct{})
|
||||
shareIDsByTunnel[tunnelIDVal] = setByTunnel
|
||||
}
|
||||
setByTunnel[shareID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range localShareRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
shareID := contractValueAsInt64(m["id"])
|
||||
currentFlow := contractValueAsInt64(m["currentFlow"])
|
||||
if shareID > 0 && currentFlow > 0 {
|
||||
if currentFlow > flowByShare[shareID] {
|
||||
flowByShare[shareID] = currentFlow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
targetForward := findForward(forwardID)
|
||||
parsedByName := contractParseShareIDFromTunnelName(contractValueAsString(targetForward["tunnelName"]))
|
||||
if parsedByName != 0 {
|
||||
t.Fatalf("expected custom tunnel name cannot be parsed as Share-*-Port-*, got %d", parsedByName)
|
||||
}
|
||||
|
||||
resolveShareIDForForward := func(forward map[string]interface{}) int64 {
|
||||
candidates := make(map[int64]struct{})
|
||||
|
||||
shareIDFromName := contractParseShareIDFromTunnelName(contractValueAsString(forward["tunnelName"]))
|
||||
if shareIDFromName > 0 {
|
||||
candidates[shareIDFromName] = struct{}{}
|
||||
}
|
||||
|
||||
tunnelIDVal := contractValueAsInt64(forward["tunnelId"])
|
||||
if setByTunnel, ok := shareIDsByTunnel[tunnelIDVal]; ok {
|
||||
for sid := range setByTunnel {
|
||||
candidates[sid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
var bestShareID int64
|
||||
bestFlow := int64(0)
|
||||
for sid := range candidates {
|
||||
flow := flowByShare[sid]
|
||||
if flow > bestFlow {
|
||||
bestFlow = flow
|
||||
bestShareID = sid
|
||||
}
|
||||
}
|
||||
return bestShareID
|
||||
}
|
||||
|
||||
resolvedShareID := resolveShareIDForForward(targetForward)
|
||||
if resolvedShareID != remoteShareID {
|
||||
t.Fatalf("expected resolved shareID=%d via tunnel binding, got %d", remoteShareID, resolvedShareID)
|
||||
}
|
||||
|
||||
forwardCountByShare := make(map[int64]int)
|
||||
resolvedByForwardID := make(map[int64]int64)
|
||||
for _, row := range forwardRows {
|
||||
m, ok := row.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fid := contractValueAsInt64(m["id"])
|
||||
sid := resolveShareIDForForward(m)
|
||||
if sid > 0 {
|
||||
resolvedByForwardID[fid] = sid
|
||||
}
|
||||
if sid > 0 && flowByShare[sid] > 0 {
|
||||
forwardCountByShare[sid] = forwardCountByShare[sid] + 1
|
||||
}
|
||||
}
|
||||
|
||||
directFlow := contractValueAsInt64(targetForward["inFlow"]) + contractValueAsInt64(targetForward["outFlow"])
|
||||
if directFlow != 0 {
|
||||
t.Fatalf("fixture expectation failed: directFlow should be 0, got %d", directFlow)
|
||||
}
|
||||
|
||||
shareFlow := flowByShare[resolvedByForwardID[forwardID]]
|
||||
if shareFlow <= 0 {
|
||||
t.Fatalf("expected merged share flow > 0 for resolved share %d", resolvedByForwardID[forwardID])
|
||||
}
|
||||
|
||||
count := forwardCountByShare[resolvedByForwardID[forwardID]]
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
estimated := shareFlow / int64(count)
|
||||
if estimated < 1 {
|
||||
estimated = 1
|
||||
}
|
||||
|
||||
displayFlow := estimated
|
||||
if displayFlow <= 0 {
|
||||
t.Fatalf("expected displayFlow > 0 after tunnel-binding-based merge, got %d", displayFlow)
|
||||
}
|
||||
if displayFlow != remoteShareFlow {
|
||||
t.Fatalf("expected displayFlow=%d, got %d", remoteShareFlow, displayFlow)
|
||||
}
|
||||
}
|
||||
|
||||
func requestContractEnvelope(t *testing.T, router http.Handler, token string, path string, body interface{}) response.R {
|
||||
t.Helper()
|
||||
|
||||
payload := []byte("{}")
|
||||
if body != nil {
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal request body for %s: %v", path, err)
|
||||
}
|
||||
payload = raw
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))
|
||||
req.Header.Set("Authorization", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
router.ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("expected http 200 for %s, got %d", path, res.Code)
|
||||
}
|
||||
|
||||
var out response.R
|
||||
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||
t.Fatalf("decode response for %s: %v", path, err)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func mustContractSlice(t *testing.T, data interface{}, label string) []interface{} {
|
||||
t.Helper()
|
||||
|
||||
rows, ok := data.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected %s to be []interface{}, got %T", label, data)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func contractParseShareIDFromTunnelName(tunnelName string) int64 {
|
||||
normalized := strings.TrimSpace(tunnelName)
|
||||
if !strings.HasPrefix(normalized, "Share-") {
|
||||
return 0
|
||||
}
|
||||
raw := strings.TrimPrefix(normalized, "Share-")
|
||||
idx := strings.Index(raw, "-Port-")
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
shareID, err := strconv.ParseInt(strings.TrimSpace(raw[:idx]), 10, 64)
|
||||
if err != nil || shareID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return shareID
|
||||
}
|
||||
|
||||
func contractValueAsInt64(v interface{}) int64 {
|
||||
switch n := v.(type) {
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
case float64:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, err := n.Int64()
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
f, err := n.Float64()
|
||||
if err == nil {
|
||||
return int64(f)
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
i, err := strconv.ParseInt(strings.TrimSpace(n), 10, 64)
|
||||
if err == nil {
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func contractValueAsString(v interface{}) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
@@ -45,6 +45,18 @@ func TestCaptchaVerifyLoginContract(t *testing.T) {
|
||||
assertCodeMsg(t, resp, -1, "验证码校验失败")
|
||||
})
|
||||
|
||||
t.Run("whmcs api client bypasses captcha", func(t *testing.T) {
|
||||
body := bytes.NewBufferString(`{"username":"admin_user","password":"admin_user","captchaId":""}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/user/login", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-FLVX-API-Client", "whmcs")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(resp, req)
|
||||
|
||||
assertCode(t, resp, 0)
|
||||
})
|
||||
|
||||
t.Run("captcha token is one-time and consumed by login", func(t *testing.T) {
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/api/v1/captcha/verify", bytes.NewBufferString(`{"id":"captcha-token-1","data":"ok"}`))
|
||||
verifyReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
VITE_API_BASE=
|
||||
VITE_GITHUB_REPO=https://github.com/Sagit-chu/flvx
|
||||
VITE_APP_VERSION=beta
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 114 KiB |
@@ -2,7 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="icon" type="image/png" href="/pwa-192x192.png" />
|
||||
<meta name="theme-color" content="#2563eb" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="FLVX" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<title>FLVX</title>
|
||||
<script>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.6.3",
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 421 KiB |
@@ -6,21 +6,21 @@ import React from "react";
|
||||
* Use this as the outermost wrapper inside each page component.
|
||||
*/
|
||||
export const AnimatedPage = ({
|
||||
children,
|
||||
className,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -28,26 +28,26 @@ export const AnimatedPage = ({
|
||||
* Children should use `staggerItem` as their `variants` prop.
|
||||
*/
|
||||
export const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.05,
|
||||
},
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.05,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual stagger item variant — fade-in + slide-up.
|
||||
*/
|
||||
export const staggerItem = {
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
hidden: { opacity: 0, y: 12 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -55,67 +55,68 @@ export const staggerItem = {
|
||||
* Renders a `motion.div` (or `motion.ul/motion.tbody`) with stagger behaviour.
|
||||
*/
|
||||
export const StaggerList = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "ul" | "tbody";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "ul" | "tbody";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component
|
||||
animate="show"
|
||||
className={className}
|
||||
initial="hidden"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
return (
|
||||
<Component
|
||||
animate="show"
|
||||
className={className}
|
||||
initial="hidden"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual animated item for use inside StaggerList.
|
||||
*/
|
||||
export const StaggerItem = ({
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
children,
|
||||
className,
|
||||
as = "div",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "li" | "tr";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
as?: "div" | "li" | "tr";
|
||||
}) => {
|
||||
const Component = motion[as] as React.ElementType;
|
||||
const Component = motion[as] as React.ElementType;
|
||||
|
||||
return (
|
||||
<Component className={className} variants={staggerItem}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
return (
|
||||
<Component className={className} variants={staggerItem}>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fade-in animation for standalone elements (chips, badges, counters, etc.)
|
||||
* Uses opacity + translateY to avoid font blurriness caused by scale transforms.
|
||||
*/
|
||||
export const FadeIn = ({
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
children,
|
||||
delay = 0,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
delay?: number;
|
||||
className?: string;
|
||||
}) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={className}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={className}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
transition={{ duration: 0.2, delay, ease: "easeOut" }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -5,97 +5,96 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { SearchIcon } from "@/components/icons";
|
||||
|
||||
interface SearchBarProps {
|
||||
isVisible: boolean;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
isVisible: boolean;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function SearchBar({
|
||||
isVisible,
|
||||
value,
|
||||
placeholder = "搜索",
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
isVisible,
|
||||
value,
|
||||
placeholder = "搜索",
|
||||
onOpen,
|
||||
onClose,
|
||||
onChange,
|
||||
}: SearchBarProps) {
|
||||
return (
|
||||
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
||||
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!isVisible ? (
|
||||
<motion.div
|
||||
key="search-btn"
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={onOpen}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex w-full items-center gap-2"
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||
inputWrapper:
|
||||
"bg-default-100 border-0 shadow-none h-8 min-h-8",
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onChange("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
// Fixed h-8 so the container never changes height — eliminates the vertical jitter
|
||||
<div className="flex items-center gap-2 h-8 overflow-hidden">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{!isVisible ? (
|
||||
<motion.div
|
||||
key="search-btn"
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="搜索"
|
||||
className="text-default-600"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={onOpen}
|
||||
>
|
||||
<SearchIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="search-input"
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="flex w-full items-center gap-2"
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
initial={{ opacity: 0, x: -16 }}
|
||||
transition={{ duration: 0.18, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Input
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input:
|
||||
"bg-transparent text-sm focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none",
|
||||
inputWrapper: "bg-default-100 border-0 shadow-none h-8 min-h-8",
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="关闭搜索"
|
||||
className="text-default-600 shrink-0"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={() => {
|
||||
onClose();
|
||||
onChange("");
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,13 +38,19 @@ function Alert({
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"h5">) {
|
||||
function AlertTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"h5">) {
|
||||
return (
|
||||
<h5
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
data-slot="alert-title"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
function CardTitle({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"h3">) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
@@ -34,7 +38,9 @@ function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
|
||||
)}
|
||||
data-slot="card-title"
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-default-200 bg-white text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="select-content"
|
||||
@@ -104,7 +104,7 @@ function SelectContent({
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { siteConfig } from "@/config/site";
|
||||
import {
|
||||
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||
type UpdateReleaseChannel,
|
||||
getLatestVersionByChannel,
|
||||
getUpdateReleaseChannel,
|
||||
hasVersionUpdate,
|
||||
} from "@/utils/version-update";
|
||||
|
||||
const FALLBACK_GITHUB_REPO = "https://github.com/Sagit-chu/flux-panel";
|
||||
|
||||
interface VersionFooterProps {
|
||||
version: string;
|
||||
containerClassName?: string;
|
||||
versionClassName?: string;
|
||||
poweredClassName?: string;
|
||||
updateBadgeClassName?: string;
|
||||
}
|
||||
|
||||
export function VersionFooter({
|
||||
version,
|
||||
containerClassName,
|
||||
versionClassName,
|
||||
poweredClassName,
|
||||
updateBadgeClassName,
|
||||
}: VersionFooterProps) {
|
||||
const [channel, setChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [latestUpdateVersion, setLatestUpdateVersion] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleChannelChange = () => {
|
||||
setChannel(getUpdateReleaseChannel());
|
||||
};
|
||||
|
||||
window.addEventListener(UPDATE_CHANNEL_CHANGED_EVENT, handleChannelChange);
|
||||
window.addEventListener("storage", handleChannelChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
UPDATE_CHANNEL_CHANGED_EVENT,
|
||||
handleChannelChange,
|
||||
);
|
||||
window.removeEventListener("storage", handleChannelChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
const checkUpdate = async () => {
|
||||
const latestVersion = await getLatestVersionByChannel(
|
||||
channel,
|
||||
siteConfig.github_repo || FALLBACK_GITHUB_REPO,
|
||||
);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
setUpdateAvailable(false);
|
||||
setLatestUpdateVersion(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const hasUpdate = hasVersionUpdate(version, latestVersion);
|
||||
|
||||
setUpdateAvailable(hasUpdate);
|
||||
setLatestUpdateVersion(hasUpdate ? latestVersion : null);
|
||||
};
|
||||
|
||||
void checkUpdate();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [channel, version]);
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
<p className={versionClassName}>
|
||||
v{version}
|
||||
{updateAvailable && latestUpdateVersion && (
|
||||
<span className={updateBadgeClassName} role="status">
|
||||
{latestUpdateVersion}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className={poweredClassName}>
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useState, type SetStateAction } from "react";
|
||||
|
||||
const readStoredValue = <T>(key: string, fallbackValue: T): T => {
|
||||
try {
|
||||
const rawValue = localStorage.getItem(key);
|
||||
|
||||
if (rawValue === null) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
return JSON.parse(rawValue) as T;
|
||||
} catch {
|
||||
return fallbackValue;
|
||||
}
|
||||
};
|
||||
|
||||
export const useLocalStorageState = <T>(
|
||||
key: string,
|
||||
initialValue: T,
|
||||
): readonly [T, (value: SetStateAction<T>) => void, () => void] => {
|
||||
const [value, setValue] = useState<T>(() =>
|
||||
readStoredValue(key, initialValue),
|
||||
);
|
||||
|
||||
const setPersistedValue = useCallback(
|
||||
(nextValue: SetStateAction<T>) => {
|
||||
setValue((prevValue) => {
|
||||
const resolvedValue =
|
||||
typeof nextValue === "function"
|
||||
? (nextValue as (value: T) => T)(prevValue)
|
||||
: nextValue;
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(resolvedValue));
|
||||
} catch {}
|
||||
|
||||
return resolvedValue;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const resetPersistedValue = useCallback(() => {
|
||||
setValue(initialValue);
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {}
|
||||
}, [initialValue, key]);
|
||||
|
||||
return [value, setPersistedValue, resetPersistedValue] as const;
|
||||
};
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Logo } from "@/components/icons";
|
||||
import { VersionFooter } from "@/components/version-footer";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { siteConfig } from "@/config/site";
|
||||
@@ -50,6 +51,9 @@ export default function AdminLayout({
|
||||
const { isOpen, onOpen, onOpenChange } = useDisclosure();
|
||||
|
||||
const [mobileMenuVisible, setMobileMenuVisible] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
() => localStorage.getItem("sidebar_collapsed") === "true",
|
||||
);
|
||||
const [username, setUsername] = useState("");
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [passwordLoading, setPasswordLoading] = useState(false);
|
||||
@@ -204,6 +208,14 @@ export default function AdminLayout({
|
||||
setMobileMenuVisible(false);
|
||||
};
|
||||
|
||||
// 切换折叠状态
|
||||
const toggleCollapse = () => {
|
||||
const newCollapsed = !isCollapsed;
|
||||
|
||||
setIsCollapsed(newCollapsed);
|
||||
localStorage.setItem("sidebar_collapsed", newCollapsed.toString());
|
||||
};
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = (path: string) => {
|
||||
navigate(path);
|
||||
@@ -304,32 +316,33 @@ export default function AdminLayout({
|
||||
className={`
|
||||
${isMobile ? "fixed" : "relative"}
|
||||
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
|
||||
${isMobile ? "w-64" : "w-72"}
|
||||
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-72"}
|
||||
bg-white dark:bg-black
|
||||
shadow-lg
|
||||
border-r border-gray-200 dark:border-gray-600
|
||||
z-50
|
||||
transition-transform duration-300 ease-in-out
|
||||
transition-all duration-300 ease-in-out
|
||||
flex flex-col
|
||||
${isMobile ? "h-screen" : "h-full"}
|
||||
${isMobile ? "top-0 left-0" : ""}
|
||||
`}
|
||||
>
|
||||
{/* Logo 区域 */}
|
||||
<div className="px-3 py-3 h-14 flex items-center">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<Logo size={24} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
<p className="text-xs text-default-500">v{siteConfig.version}</p>
|
||||
</div>
|
||||
<div className="px-5 h-14 flex items-center overflow-hidden whitespace-nowrap box-border">
|
||||
<div className="flex-shrink-0 flex items-center justify-center w-10">
|
||||
<Logo size={28} />
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[180px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<h1 className="text-sm font-bold text-foreground overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{siteConfig.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 菜单导航 */}
|
||||
<nav className="flex-1 px-4 py-6 overflow-y-auto">
|
||||
<nav className="flex-1 px-3 py-6 overflow-y-auto overflow-x-hidden">
|
||||
<ul className="space-y-1">
|
||||
{filteredMenuItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
@@ -338,22 +351,27 @@ export default function AdminLayout({
|
||||
<li key={item.path}>
|
||||
<motion.button
|
||||
className={`
|
||||
w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left
|
||||
relative min-h-[44px]
|
||||
${isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
w-full flex items-center p-2 rounded-lg text-left
|
||||
relative min-h-[44px] overflow-hidden transition-colors
|
||||
${
|
||||
isActive
|
||||
? "text-primary-600 dark:text-primary-300"
|
||||
: "text-gray-700 dark:text-gray-200"
|
||||
}
|
||||
`}
|
||||
title={isCollapsed ? item.label : undefined}
|
||||
transition={{ duration: 0.15 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => handleMenuClick(item.path)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-lg bg-primary-100 dark:bg-primary-600/20"
|
||||
layoutId="sidebar-active"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 380,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isActive && (
|
||||
@@ -363,8 +381,16 @@ export default function AdminLayout({
|
||||
whileHover={{ opacity: 1 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-shrink-0 relative z-10">{item.icon}</div>
|
||||
<span className="font-medium text-sm relative z-10">{item.label}</span>
|
||||
<div className="flex-shrink-0 w-10 h-10 flex items-center justify-center relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0 ml-0" : "max-w-[200px] opacity-100 ml-2"}`}
|
||||
>
|
||||
<span className="font-medium text-sm relative z-10 whitespace-nowrap">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
</li>
|
||||
);
|
||||
@@ -372,21 +398,61 @@ export default function AdminLayout({
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<div className="px-4 py-2 pb-4 mt-auto flex-shrink-0">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
{/* 底部版权信息和折叠按钮 */}
|
||||
<div className="px-5 py-2 pb-4 mt-auto flex-shrink-0 flex items-center justify-between overflow-hidden whitespace-nowrap box-border">
|
||||
<div
|
||||
className={`transition-all duration-300 overflow-hidden flex items-center ${isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"}`}
|
||||
>
|
||||
<VersionFooter
|
||||
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||
version={siteConfig.version}
|
||||
versionClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 桌面端折叠按钮 */}
|
||||
{!isMobile && (
|
||||
<Button
|
||||
isIconOnly
|
||||
className="flex-shrink-0 text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-300 min-w-0 w-10 h-10 rounded-full ml-auto"
|
||||
size="sm"
|
||||
variant="light"
|
||||
onPress={toggleCollapse}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
// 向右扩展的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 5l7 7-7 7M5 5l7 7-7 7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// 向左收起的提示
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -491,9 +557,7 @@ export default function AdminLayout({
|
||||
</header>
|
||||
|
||||
{/* 主内容 */}
|
||||
<main
|
||||
className={`flex-1 bg-gray-100 dark:bg-black overflow-hidden ${isMobile ? "" : "overflow-y-scroll"}`}
|
||||
>
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
|
||||
@@ -115,10 +115,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<main className="flex-1 bg-gray-100 dark:bg-black">{children}</main>
|
||||
|
||||
{/* 用于给固定 Tabbar 腾出空间的占位元素 */}
|
||||
<div aria-hidden className="h-16 safe-bottom" />
|
||||
<div aria-hidden className="h-[calc(4rem+var(--safe-area-bottom))]" />
|
||||
|
||||
{/* 底部Tabbar */}
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-16 safe-bottom flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
<nav className="bg-white dark:bg-black border-t border-gray-200 dark:border-gray-600 h-[calc(4rem+var(--safe-area-bottom))] flex-shrink-0 flex items-center justify-around px-2 fixed bottom-0 left-0 right-0 z-30">
|
||||
{filteredTabItems.map((item) => {
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
@@ -126,7 +126,7 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
|
||||
<button
|
||||
key={item.path}
|
||||
className={`
|
||||
flex flex-col items-center justify-center flex-1 h-full
|
||||
flex flex-col items-center justify-center flex-1 h-full pb-[var(--safe-area-bottom)]
|
||||
transition-colors duration-200 min-h-[44px]
|
||||
${
|
||||
isActive
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import App from "./App.tsx";
|
||||
import { Provider } from "./provider.tsx";
|
||||
import "@/styles/globals.css";
|
||||
|
||||
registerSW({ immediate: true });
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<Provider>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -16,8 +18,6 @@ import {
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
updateConfigs,
|
||||
exportBackup,
|
||||
@@ -33,6 +33,11 @@ import {
|
||||
clearConfigCache,
|
||||
updateSiteConfig,
|
||||
} from "@/config/site";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
|
||||
// 简单的保存图标组件
|
||||
const SaveIcon = ({ className }: { className?: string }) => (
|
||||
@@ -149,7 +154,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
||||
initialConfigs[key] = cachedValue;
|
||||
}
|
||||
});
|
||||
} catch { }
|
||||
} catch {}
|
||||
|
||||
return initialConfigs;
|
||||
};
|
||||
@@ -182,6 +187,9 @@ export default function ConfigPage() {
|
||||
});
|
||||
const [announcementLoading, setAnnouncementLoading] = useState(true);
|
||||
const [announcementSaving, setAnnouncementSaving] = useState(false);
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
|
||||
// 权限检查
|
||||
useEffect(() => {
|
||||
@@ -267,6 +275,14 @@ export default function ConfigPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
toast.success(
|
||||
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfigChange = (key: string, value: string) => {
|
||||
const newConfigs = { ...configs, [key]: value };
|
||||
|
||||
@@ -528,10 +544,11 @@ export default function ConfigPage() {
|
||||
<button
|
||||
key={option.value}
|
||||
aria-pressed={isSelected}
|
||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}`}
|
||||
className={`w-full px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer text-left ${
|
||||
isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}`}
|
||||
type="button"
|
||||
onClick={() => toggleTypeSelection(option.value, setTypes)}
|
||||
>
|
||||
@@ -545,10 +562,11 @@ export default function ConfigPage() {
|
||||
size="md"
|
||||
/>
|
||||
<span
|
||||
className={`font-medium ${isSelected
|
||||
? "text-default-900 dark:text-default-100"
|
||||
: "text-default-700 dark:text-default-500"
|
||||
}`}
|
||||
className={`font-medium ${
|
||||
isSelected
|
||||
? "text-default-900 dark:text-default-100"
|
||||
: "text-default-700 dark:text-default-500"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
@@ -641,6 +659,42 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<Divider className="my-2" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
更新通道
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
稳定版仅匹配纯数字版本;开发版仅匹配包含 alpha / beta / rc
|
||||
的版本。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
selectedKeys={[updateChannel]}
|
||||
size="md"
|
||||
variant="bordered"
|
||||
onSelectionChange={(keys) => {
|
||||
const selected =
|
||||
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||
|
||||
handleUpdateChannelChange(selected);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" description="仅纯数字版本,如 2.1.4">
|
||||
稳定版
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
key="dev"
|
||||
description="仅 alpha / beta / rc 关键字版本"
|
||||
>
|
||||
开发版
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnnouncementData } from "@/api";
|
||||
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -40,10 +41,10 @@ export const AnnouncementBanner = ({
|
||||
</h3>
|
||||
<div className="text-sm text-blue-800/90 dark:text-blue-100/90 break-words leading-relaxed">
|
||||
<ReactMarkdown
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
|
||||
p: ({ children }) => (
|
||||
<p className="mb-2 last:mb-0">{children}</p>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
className="underline decoration-blue-500/70 underline-offset-2 hover:text-blue-700 dark:hover:text-blue-100"
|
||||
@@ -55,10 +56,14 @@ export const AnnouncementBanner = ({
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">{children}</ul>
|
||||
<ul className="list-disc pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">{children}</ol>
|
||||
<ol className="list-decimal pl-5 space-y-1 mb-2 last:mb-0">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="rounded bg-blue-100/80 dark:bg-blue-900/40 px-1 py-0.5 text-[0.92em]">
|
||||
@@ -76,6 +81,8 @@ export const AnnouncementBanner = ({
|
||||
</blockquote>
|
||||
),
|
||||
}}
|
||||
rehypePlugins={[rehypeSanitize]}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{announcement.content}
|
||||
</ReactMarkdown>
|
||||
|
||||
+714
-335
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { title } from "@/components/primitives";
|
||||
import { VersionFooter } from "@/components/version-footer";
|
||||
import DefaultLayout from "@/layouts/default";
|
||||
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
|
||||
import { writeLoginSession } from "@/utils/session";
|
||||
@@ -154,9 +155,9 @@ export default function IndexPage() {
|
||||
<DefaultLayout>
|
||||
<section className="flex flex-col items-center justify-center gap-4 py-4 sm:py-8 md:py-10 pb-20 min-h-[calc(100dvh-120px)] sm:min-h-[calc(100dvh-200px)]">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="w-full max-w-md px-4 sm:px-0"
|
||||
initial={{ opacity: 0, y: 24, scale: 0.97 }}
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
transition={{ duration: 0.35, ease: [0.25, 0.46, 0.45, 0.94] }}
|
||||
>
|
||||
<Card className="w-full">
|
||||
@@ -213,22 +214,13 @@ export default function IndexPage() {
|
||||
|
||||
{/* 版权信息 - 固定在底部,不占据布局空间 */}
|
||||
|
||||
<div className="fixed inset-x-0 bottom-4 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{isWebView ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
<VersionFooter
|
||||
containerClassName="fixed inset-x-0 bottom-4 text-center py-4"
|
||||
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||
version={isWebView ? siteConfig.app_version : siteConfig.version}
|
||||
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||
/>
|
||||
|
||||
{/* 验证码弹层 */}
|
||||
{showCaptcha && siteKey && (
|
||||
@@ -259,9 +251,9 @@ export default function IndexPage() {
|
||||
theme: (document.documentElement.classList.contains(
|
||||
"dark",
|
||||
) ||
|
||||
document.documentElement.getAttribute("data-theme") ===
|
||||
document.documentElement.getAttribute("data-theme") ===
|
||||
"dark" ||
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light") as "light" | "dark" | "auto",
|
||||
}}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -23,6 +27,7 @@ import {
|
||||
getTunnelList,
|
||||
} from "@/api";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
interface SpeedLimitRule {
|
||||
id: number;
|
||||
@@ -53,16 +58,20 @@ export default function LimitPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [rules, setRules] = useState<SpeedLimitRule[]>([]);
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"limit-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return rules;
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
|
||||
return rules.filter(r =>
|
||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword))
|
||||
return rules.filter(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(r.tunnelName && r.tunnelName.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}, [rules, searchKeyword]);
|
||||
|
||||
|
||||
+131
-102
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -62,6 +62,7 @@ import { tryCopyInstallCommand } from "@/pages/node/install-command";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
|
||||
interface Node {
|
||||
@@ -129,11 +130,17 @@ const SortableItem = ({
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -152,7 +159,10 @@ export default function NodePage() {
|
||||
const [nodeList, setNodeList] = useState<Node[]>([]);
|
||||
const [nodeOrder, setNodeOrder] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"node-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [dialogVisible, setDialogVisible] = useState(false);
|
||||
const [dialogTitle, setDialogTitle] = useState("");
|
||||
@@ -825,23 +835,23 @@ export default function NodePage() {
|
||||
prev.map((n) =>
|
||||
n.id === form.id
|
||||
? {
|
||||
...n,
|
||||
name: form.name,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
form.serverHost?.trim() ||
|
||||
"",
|
||||
serverIpV4: form.serverIpV4,
|
||||
serverIpV6: form.serverIpV6,
|
||||
port: form.port,
|
||||
tcpListenAddr: form.tcpListenAddr,
|
||||
udpListenAddr: form.udpListenAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks,
|
||||
}
|
||||
...n,
|
||||
name: form.name,
|
||||
serverIp:
|
||||
form.serverIpV4?.trim() ||
|
||||
form.serverIpV6?.trim() ||
|
||||
form.serverHost?.trim() ||
|
||||
"",
|
||||
serverIpV4: form.serverIpV4,
|
||||
serverIpV6: form.serverIpV6,
|
||||
port: form.port,
|
||||
tcpListenAddr: form.tcpListenAddr,
|
||||
udpListenAddr: form.udpListenAddr,
|
||||
interfaceName: form.interfaceName,
|
||||
http: form.http,
|
||||
tls: form.tls,
|
||||
socks: form.socks,
|
||||
}
|
||||
: n,
|
||||
),
|
||||
);
|
||||
@@ -1002,11 +1012,13 @@ export default function NodePage() {
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
filteredNodes = filteredNodes.filter(n =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword))
|
||||
|
||||
filteredNodes = filteredNodes.filter(
|
||||
(n) =>
|
||||
(n.name && n.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIp && n.serverIp.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV4 && n.serverIpV4.toLowerCase().includes(lowerKeyword)) ||
|
||||
(n.serverIpV6 && n.serverIpV6.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1063,64 +1075,81 @@ export default function NodePage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "退出" : "批量"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作浮动工具栏 */}
|
||||
{selectMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<span className="text-sm font-medium shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={batchUpgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openUpgradeModal("batch")}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
isLoading={batchUpgradeLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => openUpgradeModal("batch")}
|
||||
>
|
||||
升级
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
批量
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleAdd}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!wsConnected && (
|
||||
<Alert
|
||||
@@ -1232,7 +1261,7 @@ export default function NodePage() {
|
||||
</span>
|
||||
<div className="text-right text-xs min-w-0 flex-1 ml-2 min-h-[2.125rem]">
|
||||
{node.serverIpV4?.trim() ||
|
||||
node.serverIpV6?.trim() ? (
|
||||
node.serverIpV6?.trim() ? (
|
||||
<div className="space-y-0.5">
|
||||
{node.serverIpV4?.trim() && (
|
||||
<span
|
||||
@@ -1288,7 +1317,7 @@ export default function NodePage() {
|
||||
</span>
|
||||
<span className="text-xs">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? formatUptime(node.systemInfo.uptime)
|
||||
: "-"}
|
||||
</span>
|
||||
@@ -1307,7 +1336,7 @@ export default function NodePage() {
|
||||
<span>CPU</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.cpuUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
@@ -1324,7 +1353,7 @@ export default function NodePage() {
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? node.systemInfo.cpuUsage
|
||||
: 0
|
||||
}
|
||||
@@ -1335,7 +1364,7 @@ export default function NodePage() {
|
||||
<span>内存</span>
|
||||
<span className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? `${node.systemInfo.memoryUsage.toFixed(1)}%`
|
||||
: "-"}
|
||||
</span>
|
||||
@@ -1352,7 +1381,7 @@ export default function NodePage() {
|
||||
size="sm"
|
||||
value={
|
||||
node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? node.systemInfo.memoryUsage
|
||||
: 0
|
||||
}
|
||||
@@ -1367,10 +1396,10 @@ export default function NodePage() {
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.uploadSpeed,
|
||||
)
|
||||
node.systemInfo.uploadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1380,10 +1409,10 @@ export default function NodePage() {
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? formatSpeed(
|
||||
node.systemInfo.downloadSpeed,
|
||||
)
|
||||
node.systemInfo.downloadSpeed,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1397,10 +1426,10 @@ export default function NodePage() {
|
||||
</div>
|
||||
<div className="font-mono text-primary-700 dark:text-primary-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.uploadTraffic,
|
||||
)
|
||||
node.systemInfo.uploadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1410,10 +1439,10 @@ export default function NodePage() {
|
||||
</div>
|
||||
<div className="font-mono text-success-700 dark:text-success-300">
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo
|
||||
node.systemInfo
|
||||
? formatTraffic(
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
node.systemInfo.downloadTraffic,
|
||||
)
|
||||
: "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,6 +375,9 @@ export default function PanelSharingPage() {
|
||||
};
|
||||
|
||||
const formatChainType = (chainType: number, hopInx: number) => {
|
||||
if (chainType === 1) {
|
||||
return "入口节点";
|
||||
}
|
||||
if (chainType === 2) {
|
||||
return `中继跳点 #${hopInx}`;
|
||||
}
|
||||
@@ -416,7 +419,8 @@ export default function PanelSharingPage() {
|
||||
Provider 共享
|
||||
</h2>
|
||||
<p className="text-sm text-default-500">
|
||||
将本地节点分享给其他面板,统一管理 Token、端口范围和到期策略。
|
||||
将本地节点分享给其他面板,统一管理
|
||||
Token、端口范围和到期策略。
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -429,7 +433,9 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : shares.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -574,7 +580,9 @@ export default function PanelSharingPage() {
|
||||
</div>
|
||||
|
||||
{remoteUsageLoading ? (
|
||||
<div className="text-center py-12 text-default-500">加载中...</div>
|
||||
<div className="text-center py-12 text-default-500">
|
||||
加载中...
|
||||
</div>
|
||||
) : remoteUsageNodes.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-divider bg-default-50/60 dark:bg-default-100/20 px-6 py-10 text-center">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
@@ -669,7 +677,11 @@ export default function PanelSharingPage() {
|
||||
</Tabs>
|
||||
|
||||
{/* Create Share Modal */}
|
||||
<Modal isOpen={createShareOpen} onClose={() => setCreateShareOpen(false)}>
|
||||
<Modal
|
||||
isOpen={createShareOpen}
|
||||
scrollBehavior="inside"
|
||||
onClose={() => setCreateShareOpen(false)}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>创建分享</ModalHeader>
|
||||
<ModalBody>
|
||||
@@ -772,7 +784,11 @@ export default function PanelSharingPage() {
|
||||
</Modal>
|
||||
|
||||
{/* Edit Share Modal */}
|
||||
<Modal isOpen={editShareOpen} onClose={() => setEditShareOpen(false)}>
|
||||
<Modal
|
||||
isOpen={editShareOpen}
|
||||
scrollBehavior="inside"
|
||||
onClose={() => setEditShareOpen(false)}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>编辑分享</ModalHeader>
|
||||
<ModalBody>
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { isWebViewFunc } from "@/utils/panel";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { VersionFooter } from "@/components/version-footer";
|
||||
import { updatePassword } from "@/api";
|
||||
import { safeLogout } from "@/utils/logout";
|
||||
import { getAdminFlag, getSessionName } from "@/utils/session";
|
||||
@@ -312,22 +313,15 @@ export default function ProfilePage() {
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="fixed inset-x-0 bottom-20 text-center py-4">
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
Powered by{" "}
|
||||
<a
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
href={siteConfig.github_repo}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
FLVX
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
v{isWebViewFunc() ? siteConfig.app_version : siteConfig.version}
|
||||
</p>
|
||||
</div>
|
||||
<VersionFooter
|
||||
containerClassName="fixed inset-x-0 bottom-20 text-center py-4"
|
||||
poweredClassName="text-xs text-gray-400 dark:text-gray-500"
|
||||
updateBadgeClassName="ml-2 inline-flex items-center rounded-full bg-rose-500/90 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-white"
|
||||
version={
|
||||
isWebViewFunc() ? siteConfig.app_version : siteConfig.version
|
||||
}
|
||||
versionClassName="text-xs text-gray-400 dark:text-gray-500 mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 修改密码弹窗 */}
|
||||
|
||||
@@ -5,7 +5,13 @@ import toast from "react-hot-toast";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { reinitializeBaseURL } from "@/api/network";
|
||||
import {
|
||||
type UpdateReleaseChannel,
|
||||
getUpdateReleaseChannel,
|
||||
setUpdateReleaseChannel,
|
||||
} from "@/utils/version-update";
|
||||
import {
|
||||
getPanelAddresses,
|
||||
savePanelAddress,
|
||||
@@ -25,6 +31,9 @@ export const SettingsPage = () => {
|
||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newAddress, setNewAddress] = useState("");
|
||||
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
|
||||
getUpdateReleaseChannel(),
|
||||
);
|
||||
|
||||
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
|
||||
setPanelAddresses(newAddress);
|
||||
@@ -79,6 +88,14 @@ export const SettingsPage = () => {
|
||||
loadPanelAddresses();
|
||||
}, []);
|
||||
|
||||
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
|
||||
setUpdateChannel(channel);
|
||||
setUpdateReleaseChannel(channel);
|
||||
toast.success(
|
||||
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-black">
|
||||
{/* 顶部导航 */}
|
||||
@@ -117,6 +134,36 @@ export const SettingsPage = () => {
|
||||
{/* 内容区域 */}
|
||||
<div className="max-w-4xl mx-auto px-4 py-6">
|
||||
<div className="space-y-6">
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||
更新设置
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
<Select
|
||||
label="更新通道"
|
||||
selectedKeys={[updateChannel]}
|
||||
onSelectionChange={(keys) => {
|
||||
const selected =
|
||||
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
|
||||
|
||||
handleUpdateChannelChange(selected);
|
||||
}}
|
||||
>
|
||||
<SelectItem key="stable" textValue="stable">
|
||||
稳定版(纯数字版本,如 2.1.4)
|
||||
</SelectItem>
|
||||
<SelectItem key="dev" textValue="dev">
|
||||
开发版(含 alpha / beta / rc)
|
||||
</SelectItem>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
版本提示会根据该通道检查最新版本。
|
||||
</p>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* 添加新地址 */}
|
||||
<Card className="border border-gray-200 dark:border-gray-700">
|
||||
<CardBody className="p-6">
|
||||
|
||||
+163
-127
@@ -1,7 +1,5 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -20,6 +18,8 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
import { SearchBar } from "@/components/search-bar";
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
getTunnelTypeDisplay,
|
||||
validateTunnelForm,
|
||||
} from "@/pages/tunnel/form";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||
import { extractApiErrorMessage } from "@/api/error-message";
|
||||
|
||||
@@ -113,7 +114,10 @@ export default function TunnelPage() {
|
||||
const [tunnels, setTunnels] = useState<Tunnel[]>([]);
|
||||
const [tunnelOrder, setTunnelOrder] = useState<number[]>([]);
|
||||
const [nodes, setNodes] = useState<Node[]>([]);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"tunnel-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
|
||||
// 模态框状态
|
||||
@@ -230,9 +234,9 @@ export default function TunnelPage() {
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
.split(",")
|
||||
.map((ip: string) => ip.trim())
|
||||
.join("\n")
|
||||
: "",
|
||||
ipPreference: tunnel.ipPreference || "",
|
||||
status: tunnel.status,
|
||||
@@ -605,9 +609,11 @@ export default function TunnelPage() {
|
||||
|
||||
if (searchKeyword.trim()) {
|
||||
const lowerKeyword = searchKeyword.toLowerCase();
|
||||
filteredTunnels = filteredTunnels.filter(t =>
|
||||
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword))
|
||||
|
||||
filteredTunnels = filteredTunnels.filter(
|
||||
(t) =>
|
||||
(t.name && t.name.toLowerCase().includes(lowerKeyword)) ||
|
||||
(t.inIp && t.inIp.toLowerCase().includes(lowerKeyword)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -666,11 +672,17 @@ export default function TunnelPage() {
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: transform ? CSS.Transform.toString(transform) : undefined,
|
||||
const style: React.CSSProperties = {
|
||||
transform: transform
|
||||
? CSS.Transform.toString({
|
||||
...transform,
|
||||
x: Math.round(transform.x),
|
||||
y: Math.round(transform.y),
|
||||
})
|
||||
: undefined,
|
||||
transition: isDragging ? undefined : transition || undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
willChange: "transform",
|
||||
willChange: isDragging ? "transform" : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -698,63 +710,81 @@ export default function TunnelPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color={selectMode ? "secondary" : "default"}
|
||||
size="sm"
|
||||
variant={selectMode ? "solid" : "flat"}
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
{selectMode ? "退出" : "批量"}
|
||||
</Button>
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectMode && selectedIds.size > 0 && (
|
||||
<div className="fixed bottom-7 left-1/2 z-50 w-[calc(100vw-1rem)] max-w-max -translate-x-1/2 overflow-x-auto rounded-lg border border-divider bg-content1 p-2 shadow-lg">
|
||||
<div className="flex min-w-max items-center gap-2">
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={batchLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleBatchRedeploy}
|
||||
>
|
||||
下发
|
||||
</Button>
|
||||
<div className="min-h-9 min-w-0 max-w-full overflow-x-auto touch-pan-x">
|
||||
<div className="flex min-h-9 w-max min-w-full items-center justify-end gap-2 whitespace-nowrap [&>*]:shrink-0">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-default-600 shrink-0">
|
||||
已选择 {selectedIds.size} 项
|
||||
</span>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={selectAll}
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={deselectAll}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => setBatchDeleteModalOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isDisabled={selectedIds.size === 0}
|
||||
isLoading={batchLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleBatchRedeploy}
|
||||
>
|
||||
下发
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="solid"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
className="bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-300 dark:hover:bg-sky-900/45"
|
||||
color="default"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={toggleSelectMode}
|
||||
>
|
||||
批量
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={handleAdd}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
@@ -1381,9 +1411,9 @@ export default function TunnelPage() {
|
||||
.filter((id) => id !== -1)
|
||||
.map((id) => id.toString()),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
label="节点"
|
||||
placeholder="选择节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={groupNodes
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())}
|
||||
@@ -1450,14 +1480,14 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{form.outNodeId &&
|
||||
form.outNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
@@ -1480,14 +1510,14 @@ export default function TunnelPage() {
|
||||
ct.nodeId !== -1,
|
||||
),
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为其他跳
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -1597,16 +1627,16 @@ export default function TunnelPage() {
|
||||
id.toString(),
|
||||
),
|
||||
]}
|
||||
dropdownPlacement="top"
|
||||
errorMessage={errors.outNodeId}
|
||||
isInvalid={!!errors.outNodeId}
|
||||
label="节点"
|
||||
placeholder="请选择出口节点(可多选)"
|
||||
dropdownPlacement="top"
|
||||
selectedKeys={
|
||||
form.outNodeId
|
||||
? form.outNodeId
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
.filter((ct) => ct.nodeId !== -1)
|
||||
.map((ct) => ct.nodeId.toString())
|
||||
: []
|
||||
}
|
||||
selectionMode="multiple"
|
||||
@@ -1673,25 +1703,25 @@ export default function TunnelPage() {
|
||||
{form.inNodeId.some(
|
||||
(ct) => ct.nodeId === node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="warning"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为入口
|
||||
</Chip>
|
||||
)}
|
||||
{getSelectedChainNodeIds().includes(
|
||||
node.id,
|
||||
) && (
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
<Chip
|
||||
color="primary"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
已选为转发链
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -2054,18 +2084,20 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`hover:bg-default-50 dark:hover:bg-gray-700/50 ${
|
||||
result.success
|
||||
? "bg-white dark:bg-gray-800"
|
||||
: "bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center text-xs ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2107,10 +2139,11 @@ export default function TunnelPage() {
|
||||
<td className="px-3 py-2 text-center">
|
||||
{result.success ? (
|
||||
<span
|
||||
className={`font-semibold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`font-semibold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</span>
|
||||
@@ -2223,17 +2256,19 @@ export default function TunnelPage() {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`border rounded-lg p-3 ${result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
className={`border rounded-lg p-3 ${
|
||||
result.success
|
||||
? "border-divider bg-white dark:bg-gray-800"
|
||||
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs flex-shrink-0 ${
|
||||
result.success
|
||||
? "bg-success text-white"
|
||||
: "bg-danger text-white"
|
||||
}`}
|
||||
>
|
||||
{result.success ? "✓" : "✗"}
|
||||
</span>
|
||||
@@ -2269,10 +2304,11 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-lg font-bold ${(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
className={`text-lg font-bold ${
|
||||
(result.packetLoss || 0) > 0
|
||||
? "text-warning"
|
||||
: "text-success"
|
||||
}`}
|
||||
>
|
||||
{result.packetLoss?.toFixed(1)}%
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { AnimatedPage, StaggerList, StaggerItem } from "@/components/animated-page";
|
||||
import { parseDate } from "@internationalized/date";
|
||||
|
||||
import {
|
||||
AnimatedPage,
|
||||
StaggerList,
|
||||
StaggerItem,
|
||||
} from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||
@@ -61,6 +65,7 @@ import {
|
||||
SettingsIcon,
|
||||
} from "@/components/icons";
|
||||
import { PageLoadingState } from "@/components/page-state";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
|
||||
// 工具函数
|
||||
const formatFlow = (value: number, unit: string = "bytes"): string => {
|
||||
@@ -121,7 +126,10 @@ export default function UserPage() {
|
||||
// 状态管理
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchKeyword, setSearchKeyword] = useState("");
|
||||
const [searchKeyword, setSearchKeyword] = useLocalStorageState(
|
||||
"user-search-keyword",
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
@@ -248,7 +256,7 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
setTunnels(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadSpeedLimits = async () => {
|
||||
@@ -258,15 +266,15 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
const speedLimitList = Array.isArray(response.data)
|
||||
? response.data.map((item) => ({
|
||||
...item,
|
||||
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
||||
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
||||
}))
|
||||
...item,
|
||||
uploadSpeed: item.uploadSpeed ?? item.speed ?? 0,
|
||||
downloadSpeed: item.downloadSpeed ?? item.speed ?? 0,
|
||||
}))
|
||||
: [];
|
||||
|
||||
setSpeedLimits(speedLimitList);
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadUserGroups = async () => {
|
||||
@@ -276,7 +284,7 @@ export default function UserPage() {
|
||||
if (response.code === 0) {
|
||||
setUserGroups(Array.isArray(response.data) ? response.data : []);
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const loadUserTunnels = async (userId: number) => {
|
||||
@@ -327,7 +335,7 @@ export default function UserPage() {
|
||||
if (groupRes.code === 0) {
|
||||
currentGroupIds = groupRes.data || [];
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
|
||||
setUserForm({
|
||||
id: user.id,
|
||||
@@ -630,7 +638,6 @@ export default function UserPage() {
|
||||
) : (
|
||||
<div className="flex w-full items-center gap-2 animate-appearance-in">
|
||||
<Input
|
||||
autoFocus
|
||||
classNames={{
|
||||
base: "bg-default-100",
|
||||
input: "bg-transparent",
|
||||
@@ -703,16 +710,14 @@ export default function UserPage() {
|
||||
const flowPercent =
|
||||
user.flow > 0
|
||||
? Math.min(
|
||||
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
|
||||
100,
|
||||
)
|
||||
(usedFlow / (user.flow * 1024 * 1024 * 1024)) * 100,
|
||||
100,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<StaggerItem key={user.id}>
|
||||
<Card
|
||||
className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full"
|
||||
>
|
||||
<Card className="shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 overflow-hidden h-full">
|
||||
<CardHeader className="pb-2 md:pb-2">
|
||||
<div className="flex justify-between items-start w-full">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -771,7 +776,9 @@ export default function UserPage() {
|
||||
<div className="space-y-1.5 pt-2 border-t border-divider">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">转发数量</span>
|
||||
<span className="font-medium text-xs">{user.num}</span>
|
||||
<span className="font-medium text-xs">
|
||||
{user.num}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">重置日期</span>
|
||||
@@ -876,7 +883,7 @@ export default function UserPage() {
|
||||
)}
|
||||
|
||||
{/* 用户表单模态框 */}
|
||||
< Modal
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
isOpen={isUserModalOpen}
|
||||
placement="center"
|
||||
@@ -971,8 +978,8 @@ export default function UserPage() {
|
||||
value={
|
||||
userForm.expTime
|
||||
? (parseDate(
|
||||
userForm.expTime.toISOString().split("T")[0],
|
||||
) as any)
|
||||
userForm.expTime.toISOString().split("T")[0],
|
||||
) as any)
|
||||
: null
|
||||
}
|
||||
onChange={(date) => {
|
||||
@@ -1078,11 +1085,12 @@ export default function UserPage() {
|
||||
aria-disabled={isAssigned}
|
||||
className={`
|
||||
px-4 py-3 rounded-lg border transition-all duration-200 cursor-pointer
|
||||
${isAssigned
|
||||
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
||||
: isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
${
|
||||
isAssigned
|
||||
? "bg-default-100/50 dark:bg-default-50/50 border-default-200/50 dark:border-default-100/20 opacity-60 cursor-not-allowed"
|
||||
: isSelected
|
||||
? "bg-primary-50 dark:bg-primary-900/20 border-primary-300 dark:border-primary-500/50 shadow-sm"
|
||||
: "bg-white dark:bg-default-50 border-default-200 dark:border-default-100/30 hover:border-primary-200 dark:hover:border-primary-500/30 hover:shadow-sm"
|
||||
}
|
||||
`}
|
||||
role="button"
|
||||
@@ -1142,13 +1150,13 @@ export default function UserPage() {
|
||||
selectedKeys={
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
null &&
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
batchTunnelSelections.get(tunnel.id) !==
|
||||
undefined
|
||||
? [
|
||||
batchTunnelSelections
|
||||
.get(tunnel.id)!
|
||||
.toString(),
|
||||
]
|
||||
batchTunnelSelections
|
||||
.get(tunnel.id)!
|
||||
.toString(),
|
||||
]
|
||||
: ["null"]
|
||||
}
|
||||
size="sm"
|
||||
@@ -1408,9 +1416,9 @@ export default function UserPage() {
|
||||
setEditTunnelForm((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
speedId: value === "null" ? null : Number(value),
|
||||
}
|
||||
...prev,
|
||||
speedId: value === "null" ? null : Number(value),
|
||||
}
|
||||
: null,
|
||||
);
|
||||
}}
|
||||
@@ -1465,10 +1473,10 @@ export default function UserPage() {
|
||||
value={
|
||||
editTunnelForm.expTime
|
||||
? (parseDate(
|
||||
new Date(editTunnelForm.expTime)
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
) as any)
|
||||
new Date(editTunnelForm.expTime)
|
||||
.toISOString()
|
||||
.split("T")[0],
|
||||
) as any)
|
||||
: null
|
||||
}
|
||||
onChange={(date) => {
|
||||
|
||||
@@ -177,6 +177,7 @@ export function DatePicker({
|
||||
required={isRequired}
|
||||
type={shouldUseTextInput ? "text" : "date"}
|
||||
value={shouldUseTextInput ? textValue : formattedValue}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
|
||||
@@ -189,7 +190,6 @@ export function DatePicker({
|
||||
|
||||
notifyNativeDateChange(nextValue);
|
||||
}}
|
||||
onBlur={shouldUseTextInput ? commitTextInput : undefined}
|
||||
onKeyDown={
|
||||
shouldUseTextInput
|
||||
? (event) => {
|
||||
|
||||
@@ -19,11 +19,18 @@ function mapColor(color: LinkColor) {
|
||||
return "text-default-600 hover:text-default-700";
|
||||
}
|
||||
|
||||
export function Link({ className, color = "default", ...props }: LinkProps) {
|
||||
export function Link({
|
||||
className,
|
||||
color = "default",
|
||||
children,
|
||||
...props
|
||||
}: LinkProps) {
|
||||
return (
|
||||
<a
|
||||
className={cn("transition-colors", mapColor(color), className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ export function ModalFooter({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
"mt-4 flex flex-wrap justify-end gap-2",
|
||||
context?.classNames?.footer,
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -286,26 +286,22 @@ export function Select<T>({
|
||||
return null;
|
||||
}
|
||||
|
||||
const placementClasses = dropdownPlacement === "top"
|
||||
? "bottom-full mb-1"
|
||||
: "top-full mt-1";
|
||||
const placementClasses =
|
||||
dropdownPlacement === "top" ? "bottom-full mb-1" : "top-full mt-1";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={listboxRef}
|
||||
className={cn(
|
||||
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
|
||||
placementClasses
|
||||
placementClasses,
|
||||
)}
|
||||
id={`${generatedId}-listbox`}
|
||||
ref={listboxRef}
|
||||
role="listbox"
|
||||
>
|
||||
{options.length === 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
"px-2 py-1 text-default-500",
|
||||
textSizeClass(size),
|
||||
)}
|
||||
className={cn("px-2 py-1 text-default-500", textSizeClass(size))}
|
||||
>
|
||||
暂无可选项
|
||||
</div>
|
||||
@@ -334,9 +330,7 @@ export function Select<T>({
|
||||
className={cn(
|
||||
"min-w-0 flex-1 truncate text-left text-foreground",
|
||||
textSizeClass(size),
|
||||
optionDisabled
|
||||
? "cursor-not-allowed"
|
||||
: "cursor-pointer",
|
||||
optionDisabled ? "cursor-not-allowed" : "cursor-pointer",
|
||||
)}
|
||||
disabled={optionDisabled}
|
||||
type="button"
|
||||
@@ -363,13 +357,7 @@ export function Select<T>({
|
||||
label={label}
|
||||
>
|
||||
{selectionMode === "multiple" ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div ref={containerRef} className={cn("relative w-full", className)}>
|
||||
<button
|
||||
aria-controls={`${generatedId}-listbox`}
|
||||
aria-expanded={isExpanded}
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
@import "tw-animate-css";
|
||||
@import "./tailwind-theme.pcss";
|
||||
|
||||
/* GPU acceleration helper - prevents font blurriness during transforms */
|
||||
.gpu-accelerated {
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root {
|
||||
--safe-area-top: env(safe-area-inset-top, 0px);
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
@@ -181,12 +189,12 @@ body {
|
||||
@keyframes captchaModalIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(-20px);
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
export type UpdateReleaseChannel = "stable" | "dev";
|
||||
|
||||
export const UPDATE_CHANNEL_STORAGE_KEY = "update-release-channel";
|
||||
export const UPDATE_CHANNEL_CHANGED_EVENT = "updateReleaseChannelChanged";
|
||||
|
||||
const CHANNEL_STABLE: UpdateReleaseChannel = "stable";
|
||||
const CHANNEL_DEV: UpdateReleaseChannel = "dev";
|
||||
|
||||
const stableVersionPattern = /^\d+(?:\.\d+)+$/;
|
||||
const testKeywordPattern = /(alpha|beta|rc)/i;
|
||||
|
||||
const VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
type ReleaseItem = {
|
||||
tag_name?: string;
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
type LatestVersionCacheEntry = {
|
||||
value: string | null;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const latestVersionCache: Record<
|
||||
UpdateReleaseChannel,
|
||||
LatestVersionCacheEntry
|
||||
> = {
|
||||
stable: { value: null, expiresAt: 0 },
|
||||
dev: { value: null, expiresAt: 0 },
|
||||
};
|
||||
|
||||
const normalizeChannel = (
|
||||
value: string | null | undefined,
|
||||
): UpdateReleaseChannel => {
|
||||
return value === CHANNEL_DEV ? CHANNEL_DEV : CHANNEL_STABLE;
|
||||
};
|
||||
|
||||
export const getUpdateReleaseChannel = (): UpdateReleaseChannel => {
|
||||
if (typeof window === "undefined") {
|
||||
return CHANNEL_STABLE;
|
||||
}
|
||||
|
||||
return normalizeChannel(localStorage.getItem(UPDATE_CHANNEL_STORAGE_KEY));
|
||||
};
|
||||
|
||||
export const setUpdateReleaseChannel = (
|
||||
channel: UpdateReleaseChannel,
|
||||
): void => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem(UPDATE_CHANNEL_STORAGE_KEY, normalizeChannel(channel));
|
||||
window.dispatchEvent(new Event(UPDATE_CHANNEL_CHANGED_EVENT));
|
||||
};
|
||||
|
||||
const normalizeTag = (tag: string): string => {
|
||||
return tag.trim().replace(/^v/i, "");
|
||||
};
|
||||
|
||||
type ReleaseTagChannel = UpdateReleaseChannel | null;
|
||||
|
||||
const releaseChannelFromTag = (tag: string): ReleaseTagChannel => {
|
||||
const normalizedTag = normalizeTag(tag).toLowerCase();
|
||||
|
||||
if (!normalizedTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (stableVersionPattern.test(normalizedTag)) {
|
||||
return CHANNEL_STABLE;
|
||||
}
|
||||
|
||||
if (testKeywordPattern.test(normalizedTag)) {
|
||||
return CHANNEL_DEV;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
type VersionParts = {
|
||||
numbers: number[];
|
||||
stageRank: number;
|
||||
stageNumber: number;
|
||||
};
|
||||
|
||||
const parseVersionParts = (version: string): VersionParts => {
|
||||
const normalized = normalizeTag(version).toLowerCase();
|
||||
const numberMatches = normalized.match(/\d+/g) || [];
|
||||
const numbers = numberMatches.map((item) => Number.parseInt(item, 10));
|
||||
|
||||
let stageRank = 0;
|
||||
|
||||
if (normalized.includes("rc")) {
|
||||
stageRank = 3;
|
||||
} else if (normalized.includes("beta")) {
|
||||
stageRank = 2;
|
||||
} else if (normalized.includes("alpha")) {
|
||||
stageRank = 1;
|
||||
} else if (stableVersionPattern.test(normalized)) {
|
||||
stageRank = 4;
|
||||
}
|
||||
|
||||
const stageNumberMatch = normalized.match(/(?:alpha|beta|rc)[.-]?(\d+)/);
|
||||
const stageNumber = stageNumberMatch
|
||||
? Number.parseInt(stageNumberMatch[1], 10)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
numbers,
|
||||
stageRank,
|
||||
stageNumber,
|
||||
};
|
||||
};
|
||||
|
||||
export const compareVersions = (left: string, right: string): number => {
|
||||
const a = parseVersionParts(left);
|
||||
const b = parseVersionParts(right);
|
||||
const maxLength = Math.max(a.numbers.length, b.numbers.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i += 1) {
|
||||
const aValue = a.numbers[i] || 0;
|
||||
const bValue = b.numbers[i] || 0;
|
||||
|
||||
if (aValue !== bValue) {
|
||||
return aValue - bValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (a.stageRank !== b.stageRank) {
|
||||
return a.stageRank - b.stageRank;
|
||||
}
|
||||
|
||||
if (a.stageNumber !== b.stageNumber) {
|
||||
return a.stageNumber - b.stageNumber;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const repoPathFromUrl = (repoUrl: string): string | null => {
|
||||
try {
|
||||
const parsed = new URL(repoUrl);
|
||||
const segments = parsed.pathname
|
||||
.replace(/\.git$/i, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
|
||||
if (segments.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${segments[0]}/${segments[1]}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestVersionByChannel = async (
|
||||
channel: UpdateReleaseChannel,
|
||||
repoUrl: string,
|
||||
): Promise<string | null> => {
|
||||
const normalizedChannel = normalizeChannel(channel);
|
||||
const now = Date.now();
|
||||
const cached = latestVersionCache[normalizedChannel];
|
||||
|
||||
if (cached.value && cached.expiresAt > now) {
|
||||
return cached.value;
|
||||
}
|
||||
|
||||
const repoPath = repoPathFromUrl(repoUrl);
|
||||
|
||||
if (!repoPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/${repoPath}/releases?per_page=50`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releases = (await response.json()) as ReleaseItem[];
|
||||
const candidateTags = releases
|
||||
.filter((release) => !release.draft && typeof release.tag_name === "string")
|
||||
.map((release) => (release.tag_name || "").trim())
|
||||
.filter((tag) => releaseChannelFromTag(tag) === normalizedChannel);
|
||||
|
||||
if (candidateTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const latest = candidateTags.sort((a, b) => compareVersions(b, a))[0];
|
||||
|
||||
latestVersionCache[normalizedChannel] = {
|
||||
value: latest,
|
||||
expiresAt: now + VERSION_CACHE_TTL_MS,
|
||||
};
|
||||
|
||||
return latest;
|
||||
};
|
||||
|
||||
export const hasVersionUpdate = (
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
): boolean => {
|
||||
return (
|
||||
compareVersions(normalizeTag(currentVersion), normalizeTag(latestVersion)) <
|
||||
0
|
||||
);
|
||||
};
|
||||
Vendored
+9
@@ -1,4 +1,5 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_VERSION?: string;
|
||||
@@ -8,3 +9,11 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "virtual:pwa-register" {
|
||||
export function registerSW(options?: {
|
||||
immediate?: boolean;
|
||||
onNeedRefresh?: () => void;
|
||||
onOfflineReady?: () => void;
|
||||
}): (reloadPage?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,50 @@ import path from "path";
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "autoUpdate",
|
||||
injectRegister: "auto",
|
||||
includeAssets: ["favicon.ico", "apple-touch-icon.png"],
|
||||
manifest: {
|
||||
name: "FLVX",
|
||||
short_name: "FLVX",
|
||||
description: "FLVX forwarding management panel",
|
||||
theme_color: "#2563eb",
|
||||
background_color: "#f6f7fb",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
icons: [
|
||||
{
|
||||
src: "pwa-192x192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
},
|
||||
{
|
||||
src: "pwa-maskable-512x512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: "/index.html",
|
||||
cleanupOutdatedCaches: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
],
|
||||
base: "/",
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
Reference in New Issue
Block a user