Compare commits

...

42 Commits

Author SHA1 Message Date
sagit 701b4011cb feat: move tunnel quality monitor toggle from /settings to /config (#393)
将实时隧道质量检测开关从 /settings 移到 /config 页面,统一管理全局配置项。
2026-03-29 15:48:45 +08:00
sagitchu d128d2f657 feat: move tunnel quality monitor toggle from /settings to /config 2026-03-29 15:47:10 +08:00
sagit 400a40fe80 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#392)
## 修复内容

修复 #342

### 问题
当节点离线时,用户无法编辑隧道配置(包括更换节点),也无法修改相关的转发规则。

### 变更

**Backend:**
- `prepareTunnelCreateState`: 更新隧道时,允许已关联的离线节点保留(用户可能在移除它们),仅拒绝新增的离线节点
- `syncForwardServicesWithWarnings`: 离线节点跳过下发并返回警告,不再硬性失败
- `applyTunnelRuntime`: 所有节点类型(入口/转发链/出口)均支持离线错误延迟处理
- 新增 `isNodeOfflineOrTimeoutError` 辅助函数

**Frontend:**
- `validateTunnelForm`: 新增 `isEdit` 参数,编辑模式下跳过离线节点验证
- `tunnel.tsx`: 传递 `isEdit` 标志到表单验证
2026-03-28 19:31:54 +08:00
sagitchu 103290ed35 fix: 节点离线时允许删除隧道关联,但禁止新增隧道 (#342)
- prepareTunnelCreateState: 更新隧道时允许已关联的离线节点,仅拒绝新增的离线节点
- syncForwardServicesWithWarnings: 离线节点跳过下发并返回警告,不再硬性失败
- applyTunnelRuntime: 所有节点类型均支持离线错误延迟处理
- 前端 validateTunnelForm: 编辑模式下跳过离线节点验证

Closes #342
2026-03-28 19:30:30 +08:00
sagitchu 363e714603 fix: 支持跨版本隧道链路 (v6入v4出 / v4入v6出)
问题:selectTunnelDialHost 只检查同版本兼容 (v4->v4, v6->v6),
导致 v6-only 入口节点连接 v4-only 出口节点时报错:
"节点链路不兼容"

修复:在 default 分支增加跨版本支持:
- fromV6 && toV4 → 返回出口 v4 地址
- fromV4 && toV6 → 返回出口 v6 地址

更新测试用例以反映新行为
2026-03-28 10:35:47 +08:00
sagit afd1258fcd fix(monitor): add tunnel quality detection toggle (#386)
## Summary
- add a global settings toggle to enable or disable real-time tunnel
quality detection
- stop frontend tunnel quality polling and related status UI when the
toggle is off
- gate the backend tunnel quality prober so disabling the setting also
stops server-side probing

## Test plan
- [x] cd go-backend && go test ./...
- [x] cd vite-frontend && npm run build

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-03-26 20:10:31 +08:00
sagitchu e69082a596 fix(monitor): add tunnel quality detection toggle
Allow admins to disable real-time tunnel quality probing from settings so the monitor UI and backend probe loop stop together.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:08:40 +08:00
sagit d30363d164 docs: update agent context to 2.1.9-rc10 (#385) 2026-03-25 20:27:40 +08:00
sagitchu 8e1a87bf5a docs: update agent context to 2.1.9-rc10 2026-03-25 20:26:09 +08:00
sagit 6180b5a198 fix(backend): clean up forwards when revoking tunnel permissions (#384) 2026-03-25 20:25:49 +08:00
sagitchu 61d95ab5d5 fix(backend): clean up forwards when revoking tunnel permissions 2026-03-25 20:24:27 +08:00
sagit c27be19915 fix: bracket IPv6 forward entry addresses (#383)
Return standard [host]:port values from forward list responses so IPv6 rule entries can be copied directly from the UI.
2026-03-24 22:16:55 +08:00
sagit f62a35c3f9 fix: resolve PostgreSQL type encoding issues for node metrics (#381)
Fixed OID 25 (text) inference failing to encode int64 by embedding the
node ID format string directly. Replaced CAST(x AS INTEGER) with CAST(x
AS BIGINT) to prevent 32-bit overflow on Unix milliseconds timestamps.
2026-03-24 10:46:29 +08:00
sagitchu 2a1caf32c4 fix(monitor): resolve PostgreSQL type encoding issues for node metrics 2026-03-24 10:45:17 +08:00
sagit fdcc30a493 release: 2.1.9-rc8 (#380)
Release 2.1.9-rc8
2026-03-24 09:46:39 +08:00
sagitchu efaffb0475 chore: release 2.1.9-rc8 2026-03-24 09:45:21 +08:00
sagitchu 4954526cbc chore: finalize release plan 064 2026-03-24 07:23:23 +08:00
sagit 9d50071915 chore: release 2.1.9-rc7 (#379)
Bump version to 2.1.9-rc7 in AGENTS.md
2026-03-24 07:22:52 +08:00
sagitchu ceceee6ebd chore: bump version to 2.1.9-rc7 2026-03-24 07:21:20 +08:00
sagitchu 11051f5517 chore: finalize release plan 063 2026-03-24 07:18:56 +08:00
sagit ff2c7c4959 chore: release 2.1.9-rc6 (#378)
Bump version to 2.1.9-rc6 in AGENTS.md
2026-03-24 07:18:26 +08:00
sagitchu 6364b96935 chore: bump version to 2.1.9-rc6 2026-03-24 07:16:01 +08:00
sagit 409f0a232a fix: bump npm package version to 2.1.9-rc5 (#376)
Bumps version in package.json and AGENTS.md
2026-03-24 00:58:34 +08:00
sagitchu f79994e0e0 fix: bump npm package version to 2.1.9-rc5 to fix CI error 2026-03-24 00:56:08 +08:00
sagit 9fdb16d035 chore: bump version to 2.1.9-rc4 (#375)
Bump version tag in AGENTS.md
2026-03-23 23:01:58 +08:00
sagitchu 53b632a6f7 chore: bump version to 2.1.9-rc4 2026-03-23 22:59:47 +08:00
sagit bf7b2a0740 fix: re-assign tunnel ports automatically for out-of-range entries (#373) (#374)
Automatically re-assign ports from available ranges instead of rejecting
tunnel modification when adding new entry nodes.
2026-03-23 22:59:13 +08:00
sagitchu 8475bc27bb fix: re-assign port automatically if out of range for new tunnel entries (fixes #373) 2026-03-23 22:57:49 +08:00
sagit 5d01572eff release: 2.1.9-rc3 (#371)
Sync all changes and fix service monitor stale detection
2026-03-22 19:06:43 +08:00
sagitchu a353faaa71 chore: release 2.1.9-rc3 (fix service monitor stale detection) 2026-03-22 19:05:31 +08:00
sagit e7b25004ba Update monitor rendering (#370)
Merge all changes into main for RC2 release.
2026-03-22 17:56:14 +08:00
sagitchu 3826cb02c0 chore: update monitor rendering and release rc2 2026-03-22 17:55:10 +08:00
sagitchu a1fee8e432 Merge remote-tracking branch 'origin/main' into fix-agents-v14-final 2026-03-22 13:33:58 +08:00
sagitchu aafdb78482 feat: node logo by distro types and official icons 2026-03-22 13:33:37 +08:00
sagit 16b545d8cd chore: update AGENTS.md for release 2.1.9-beta14 (#368) 2026-03-22 05:05:50 +00:00
sagitchu 45065178b8 chore: update AGENTS.md for release 2.1.9-beta14 2026-03-22 13:04:43 +08:00
sagit 8ebde9dca9 feat: node OS logo and UI fixes (#367)
* chore: update AGENTS.md with next release info

* feat: node OS logo, UI rate fix, and monitor trend updates
2026-03-22 05:03:04 +00:00
sagit 0a1ec60750 fix: qualify tunnel_metric columns (#366)
fix ambiguous column reference in tunnel_metric upserts
2026-03-21 19:42:00 +08:00
sagitchu 9ec35d2f2f merge main into sync-agents-v12 2026-03-21 19:40:44 +08:00
sagitchu c914040b7d fix(repo): qualify tunnel_metric columns to avoid ambiguity in ON CONFLICT 2026-03-21 19:40:16 +08:00
sagit 822362c44c Update AGENTS.md for release v2.1.9-beta12 (#365)
Updating commit SHA and tag in AGENTS.md for the latest release.
2026-03-21 19:24:51 +08:00
sagitchu 80f5935b76 chore: update AGENTS.md for release v2.1.9-beta12 2026-03-21 19:23:20 +08:00
37 changed files with 1439 additions and 488 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Sat Mar 21 2026
**Commit:** ac35068
**Generated:** Tue Mar 24 2026
**Commit:** 8ebde9d
**Branch:** main
**Tag:** 2.1.9-beta10
**Tag:** 2.1.9-rc10
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
for _, fp := range ports {
if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
// If the limiter push fails because the node is offline, skip it with a warning
if isNodeOfflineOrTimeoutError(err) {
node, _ := h.getNodeRecord(fp.NodeID)
nodeName := fmt.Sprintf("%d", fp.NodeID)
if node != nil && strings.TrimSpace(node.Name) != "" {
nodeName = strings.TrimSpace(node.Name)
}
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
continue
}
return nil, err
}
}
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
warnings = append(warnings, warning)
}
}
// When a node is offline, skip it with a warning instead of failing.
// This lets users modify forward rules even when some entry nodes are down.
if err != nil && isNodeOfflineOrTimeoutError(err) {
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
continue
}
if err != nil {
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
}
@@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
}
}
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
from := v4OnlyNode("from", "10.0.0.1")
to := v6OnlyNode("to", "2001:db8::2")
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
}
if host != "2001:db8::2" {
t.Fatalf("expected v6 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
from := v6OnlyNode("from", "2001:db8::1")
to := v4OnlyNode("to", "10.0.0.2")
host, err := selectTunnelDialHost(from, to, "", "")
if err != nil {
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
}
if host != "10.0.0.2" {
t.Fatalf("expected v4 address for cross-version, got %q", host)
}
}
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
// 真正不兼容:两个节点都没有任何 IP
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
_, err := selectTunnelDialHost(from, to, "", "")
if err == nil {
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
t.Fatal("expected error for nodes with no IP addresses")
}
}
@@ -46,6 +46,8 @@ type Handler struct {
qualityProber *tunnelQualityProber
}
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -886,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
}
return pngDataURLPrefix + payload, nil
case monitorTunnelQualityEnabledConfigKey:
normalized := strings.TrimSpace(strings.ToLower(value))
switch normalized {
case "true", "false":
return normalized, nil
default:
return "", fmt.Errorf("隧道质量检测开关配置值无效")
}
default:
return value, nil
}
}
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
if h == nil || h.repo == nil {
return true
}
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
if err != nil || cfg == nil {
return true
}
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
}
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
+4 -2
View File
@@ -66,9 +66,11 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h.qualityProber != nil {
h.qualityProber.Start(ctx)
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
return
}
h.qualityProber.Start(ctx)
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
@@ -71,6 +71,7 @@ type monitorNodeListItem struct {
Inx int `json:"inx"`
Name string `json:"name"`
Status int `json:"status"`
Version string `json:"version"`
UpdatedTime int64 `json:"updatedTime"`
}
@@ -100,6 +101,7 @@ func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request)
Inx: n.Inx,
Name: n.Name,
Status: n.Status,
Version: n.Version.String,
UpdatedTime: updated,
})
}
+194 -18
View File
@@ -1102,24 +1102,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
if err != nil {
continue
}
port := pickForwardPortFromRecords(oldPorts)
if port <= 0 {
referencePort := pickForwardPortFromRecords(oldPorts)
if referencePort <= 0 {
continue
}
var entries []forwardPortReplaceEntry
if allowInIP {
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
} else {
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
// Build a map of existing node → port/inIP from old records.
oldPortByNode := make(map[int64]forwardPortRecord)
for _, fp := range oldPorts {
if fp.NodeID > 0 {
oldPortByNode[fp.NodeID] = fp
}
}
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
for _, nid := range entryNodeIDs {
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
// Existing entry node: keep its current port.
inIP := existing.InIP
if !allowInIP {
inIP = ""
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
continue
}
// New entry node: try to follow the reference port.
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
inIP := ""
if allowInIP {
// For single-entry tunnels, try to preserve inIP from old records.
for _, fp := range oldPorts {
if strings.TrimSpace(fp.InIP) != "" {
inIP = fp.InIP
break
}
}
}
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
}
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
}
}
// resolvePortForNewEntryNode determines the port for a forward on a newly added
// entry node. It tries to reuse referencePort (from existing entries); if that
// port is out of range or already occupied, it picks a random available port
// for this specific node.
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
node, err := h.getNodeRecord(nodeID)
if err != nil {
return referencePort
}
// Check if referencePort is within the node's allowed range.
if validateLocalNodePort(node, referencePort) == nil &&
validateRemoteNodePort(node, referencePort) == nil {
// In range — check availability.
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
if occErr == nil && !occupied {
return referencePort
}
}
// referencePort doesn't work for this node; pick a random one.
newPort := h.pickRandomPortForNode(nodeID)
if newPort > 0 {
return newPort
}
return referencePort // last resort fallback
}
// pickRandomPortForNode picks a random available port from a single node's
// port range, excluding ports already occupied by other forwards or chains.
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
portRange, err := h.repo.GetNodePortRange(nodeID)
if err != nil {
return 0
}
if portRange == "" {
portRange = "1000-65535"
}
nodePorts, err := parsePorts(portRange)
if err != nil || len(nodePorts) == 0 {
return 0
}
used, err := h.getUsedPorts(nodeID)
if err != nil {
return 0
}
var available []int
for _, p := range nodePorts {
if !used[p] {
available = append(available, p)
}
}
if len(available) == 0 {
return 0
}
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
return available[idx.Int64()]
}
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -1468,6 +1557,12 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
if id <= 0 {
return
}
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
if lookupErr != nil {
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
return
}
h.cleanupForwardsForUserTunnel(userID, tunnelID)
if err := h.repo.DeleteUserTunnel(id); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
@@ -2394,14 +2489,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
return
}
if err := tx.Commit().Error; err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
_ = h.syncPermissionsByUserGroup(req.GroupID)
response.WriteJSON(w, response.OKEmpty())
}
@@ -2445,9 +2544,12 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
var revokedPairs []repo.RevokedUserTunnelPair
if exists {
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
var revokeErr error
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
if revokeErr != nil {
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
return
}
}
@@ -2456,6 +2558,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
for _, pair := range revokedPairs {
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
}
response.WriteJSON(w, response.OKEmpty())
}
@@ -2675,6 +2780,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
}
}
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
// node IDs that already belong to the tunnel so we can tolerate offline
// nodes that the user is keeping or removing, while still rejecting newly
// added offline nodes.
existingNodeIDs := make(map[int64]struct{})
if excludeTunnelID > 0 {
var existIDs []int64
if err := tx.Model(&model.ChainTunnel{}).
Where("tunnel_id = ?", excludeTunnelID).
Pluck("node_id", &existIDs).Error; err == nil {
for _, eid := range existIDs {
existingNodeIDs[eid] = struct{}{}
}
}
}
seen := make(map[int64]struct{}, len(nodeIDs))
for _, nodeID := range nodeIDs {
if _, ok := seen[nodeID]; ok {
@@ -2693,7 +2814,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
return nil, errors.New("节点不存在")
}
if node.IsRemote != 1 && node.Status != 1 {
return nil, errors.New("部分节点不在线")
// For tunnel updates, allow offline nodes that already belong to the
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
_, isExisting := existingNodeIDs[nodeID]
if excludeTunnelID <= 0 || !isExisting {
return nil, errors.New("部分节点不在线")
}
}
state.Nodes[nodeID] = node
}
@@ -3093,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -3103,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
@@ -3117,7 +3242,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
nextTargets = state.ChainHops[i+1]
}
for _, chainNode := range hop {
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
node := state.Nodes[chainNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
continue
}
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
@@ -3125,12 +3251,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdChains = append(createdChains, chainNode.NodeID)
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
}
createdServices = append(createdServices, chainNode.NodeID)
@@ -3138,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, outNode := range state.OutNodes {
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
node := state.Nodes[outNode.NodeID]
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
continue
}
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
if shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
}
createdServices = append(createdServices, outNode.NodeID)
@@ -3233,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
return false
}
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
// is unreachable (offline or timed out), matching the same patterns used by
// shouldDeferTunnelRuntimeApplyError.
func isNodeOfflineOrTimeoutError(err error) bool {
return shouldDeferTunnelRuntimeApplyError(err)
}
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
fromNode := nodes[fromNodeID]
if fromNode == nil {
@@ -3359,6 +3502,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
}
}
default:
// 同版本优先
if fromV4 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
@@ -3369,6 +3513,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
return host, nil
}
}
// 跨版本支持:v6入v4出 / v4入v6出
if fromV6 && toV4 {
if host := pickNodeAddressV4(toNode); host != "" {
return host, nil
}
}
if fromV4 && toV6 {
if host := pickNodeAddressV6(toNode); host != "" {
return host, nil
}
}
}
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
}
@@ -3911,6 +4066,27 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
return nil
}
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
// specific user+tunnel pair. It notifies nodes to remove the runtime services
// first, then deletes the DB records. This is best-effort: individual failures
// do not abort the overall cleanup so that remaining forwards are still cleaned.
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
if userID <= 0 || tunnelID <= 0 {
return
}
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
if err != nil || len(forwards) == 0 {
return
}
for i := range forwards {
f := &forwards[i]
if f.Status == 1 {
_ = h.controlForwardServices(f, "DeleteService", true)
}
_ = h.deleteForwardByID(f.ID)
}
}
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
if speedID == nil {
return nil, nil
@@ -48,11 +48,8 @@ type tunnelQualityProber struct {
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
@@ -66,6 +63,10 @@ func (p *tunnelQualityProber) Start(ctx context.Context) {
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
if p == nil || p.cancel == nil {
return
}
p.cancel()
}
@@ -106,8 +107,20 @@ func (p *tunnelQualityProber) loop() {
}
}
func (p *tunnelQualityProber) isEnabled() bool {
if p == nil || p.handler == nil {
return true
}
return p.handler.isTunnelQualityMonitoringEnabled()
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
if !p.isEnabled() {
return
}
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
@@ -126,6 +139,10 @@ func (p *tunnelQualityProber) maybePrune() {
}
func (p *tunnelQualityProber) probeAll() {
if !p.isEnabled() {
return
}
// Skip if previous probe round is still running (interval < timeout guard)
if !atomic.CompareAndSwapInt32(&p.probing, 0, 1) {
return
@@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) {
time.Sleep(100 * time.Millisecond)
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) {
<-ctx.Done()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) {
svc.flushNodeMetrics()
metrics, err := svc.GetMetrics(1, 0, now+1000)
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) {
svc.pruneMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
@@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) {
svc.flushNodeMetrics()
for nodeID := int64(1); nodeID <= 3; nodeID++ {
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics for node %d: %v", nodeID, err)
}
@@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) {
svc.RecordNodeMetric(1, info)
svc.flushNodeMetrics()
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
+71 -13
View File
@@ -5,9 +5,11 @@ import (
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@@ -3241,7 +3243,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
}
if ip != "" {
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
pair := formatForwardIngressAddress(ip, row.Port.Int64)
if _, ok := seenPairs[pair]; !ok {
seenPairs[pair] = struct{}{}
entries = append(entries, pair)
@@ -3258,6 +3260,17 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
return strings.Join(entries, ","), inPort, nil
}
func formatForwardIngressAddress(host string, port int64) string {
host = strings.TrimSpace(host)
if host == "" || port <= 0 {
return ""
}
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
}
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
}
func nullableString(v sql.NullString) interface{} {
if v.Valid {
return v.String
@@ -3320,15 +3333,60 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
if r == nil || r.db == nil {
return nil, nil
}
rangeMs := endMs - startMs
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
const targetPoints = 500 // target number of chart points for downsampled data
// For short ranges, return raw data (full resolution).
if rangeMs <= maxRawRangeMs {
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp ASC").
Limit(5000).
Find(&metrics).Error
return metrics, err
}
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
bucketMs := rangeMs / targetPoints
if bucketMs < 1000 {
bucketMs = 1000 // minimum 1-second buckets
}
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
var metrics []model.NodeMetric
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&metrics).Error
if len(metrics) > 1 {
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
metrics[i], metrics[j] = metrics[j], metrics[i]
}
err := r.db.Model(&model.NodeMetric{}).
Select(
fmt.Sprintf(
"%d AS node_id, "+
"CAST(%s AS BIGINT) AS timestamp, "+
"AVG(cpu_usage) AS cpu_usage, "+
"AVG(mem_usage) AS mem_usage, "+
"AVG(disk_usage) AS disk_usage, "+
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
"AVG(load1) AS load1, "+
"AVG(load5) AS load5, "+
"AVG(load15) AS load15, "+
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
"CAST(MAX(uptime) AS BIGINT) AS uptime",
nodeID, bucketExpr,
),
).
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
Group(groupExpr).
Order("timestamp ASC").
Limit(targetPoints + 100). // safety margin
Scan(&metrics).Error
if metrics == nil {
metrics = make([]model.NodeMetric, 0)
}
return metrics, err
}
@@ -3422,10 +3480,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
"connections": gorm.Expr("connections + excluded.connections"),
"errors": gorm.Expr("errors + excluded.errors"),
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
// avg_latency_ms is not additive; keep the existing bucket value.
}),
}).CreateInBatches(rows, 100).Error
@@ -80,6 +80,37 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
return rows, nil
}
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var forwards []model.Forward
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
if err != nil {
return nil, err
}
rows := make([]model.ForwardRecord, 0, len(forwards))
for _, f := range forwards {
rows = append(rows, model.ForwardRecord{
ID: f.ID,
UserID: f.UserID,
UserName: f.UserName,
Name: f.Name,
TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy,
Status: f.Status,
SpeedID: f.SpeedID,
})
}
for i := range rows {
if strings.TrimSpace(rows[i].Strategy) == "" {
rows[i].Strategy = "fifo"
}
}
return rows, nil
}
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
@@ -11,7 +11,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
return nil, errors.New("repository not initialized")
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "updated_time").
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
Where("is_remote = ?", 0).
Order("inx ASC, id ASC").
Find(&nodes).Error
@@ -997,9 +997,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
}
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
// so the handler layer can clean up associated forwarding rules.
type RevokedUserTunnelPair struct {
UserID int64
TunnelID int64
}
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
if tx == nil {
return errors.New("database unavailable")
return nil, errors.New("database unavailable")
}
currentSet := make(map[int64]struct{}, len(currentUserIDs))
for _, uid := range currentUserIDs {
@@ -1018,7 +1025,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
}
}
if len(removedUserIDs) == 0 {
return nil
return nil, nil
}
type grantRow struct {
@@ -1026,6 +1033,8 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
CreatedByGroup int
}
var revoked []RevokedUserTunnelPair
for _, userID := range removedUserIDs {
var rows []grantRow
if err := tx.Model(&model.GroupPermissionGrant{}).
@@ -1033,7 +1042,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
Find(&rows).Error; err != nil {
return err
return revoked, err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1046,28 +1055,32 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return err
return revoked, err
}
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return err
return revoked, err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return err
return revoked, err
}
}
}
}
return nil
return revoked, nil
}
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
if tx == nil {
return errors.New("database unavailable")
return nil, errors.New("database unavailable")
}
type grantRow struct {
@@ -1080,7 +1093,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
Select("user_tunnel_id, created_by_group").
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Find(&rows).Error; err != nil {
return err
return nil, err
}
groupCreatedTunnelIDs := make(map[int64]struct{})
@@ -1092,22 +1105,27 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
return err
return nil, err
}
var revoked []RevokedUserTunnelPair
for userTunnelID := range groupCreatedTunnelIDs {
var remaining int64
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
return err
return revoked, err
}
if remaining == 0 {
var ut model.UserTunnel
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
}
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
return err
return revoked, err
}
}
}
return nil
return revoked, nil
}
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
@@ -0,0 +1,80 @@
package contract_test
import (
"testing"
"time"
)
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken := mustAdminToken(t, secret)
now := time.Now().UnixMilli()
if err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
tunnelID := mustLastInsertID(t, repo, "issue349-tunnel")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node a: %v", err)
}
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
if err := repo.DB().Exec(`
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
t.Fatalf("insert node b: %v", err)
}
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
if err := repo.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(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
t.Fatalf("insert forward: %v", err)
}
forwardID := mustLastInsertID(t, repo, "issue349-forward")
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
t.Fatalf("insert forward_port a: %v", err)
}
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
t.Fatalf("insert forward_port b: %v", err)
}
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
if out.Code != 0 {
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
}
rows := mustContractSlice(t, out.Data, "forward list data")
var target map[string]interface{}
for _, row := range rows {
item, ok := row.(map[string]interface{})
if !ok {
continue
}
if contractValueAsInt64(item["id"]) == forwardID {
target = item
break
}
}
if target == nil {
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
}
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
}
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
t.Fatalf("expected first entry port 32001, got %d", got)
}
}
@@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) {
t.Fatalf("batch insert: %v", err)
}
retrieved, err := repo.GetNodeMetrics(1, 0, now+1000)
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
if err != nil {
t.Fatalf("get metrics: %v", err)
}
+3 -1
View File
@@ -119,7 +119,9 @@ func main() {
log := xlogger.NewLogger()
logger.SetDefault(log)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
distro := socket.DetectDistro()
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
defer wsReporter.Stop()
service.SetHTTPReportURL(config.Addr, config.Secret)
+21
View File
@@ -0,0 +1,21 @@
package socket
import (
"strings"
"github.com/shirou/gopsutil/v3/host"
)
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
// "debian"). Falls back to "linux" when detection fails.
func DetectDistro() string {
info, err := host.Info()
if err != nil || info == nil {
return "linux"
}
platform := strings.ToLower(strings.TrimSpace(info.Platform))
if platform == "" {
return "linux"
}
return platform
}
+1 -1
View File
@@ -348,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
normalizedAddr = strings.TrimSpace(addr)
}
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
schemes := []string{"wss", "ws"}
+21
View File
@@ -0,0 +1,21 @@
# 013 - 隧道入口端口校验不严格修复
## Issue
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
## 修复方案
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
- **旧入口节点**:保留原端口不变
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
## 任务清单
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
- [x] 5. 构建通过 + 全量测试通过
+15
View File
@@ -0,0 +1,15 @@
# Plan 061: Node Logo By OS Type (Linux Distro)
## Objective
Display different logos for nodes in the 'Monitor - Node - Card/List View' based on their Linux distribution (Ubuntu, Debian, CentOS, Alpine, etc.).
## Tasks
- [x] Agent: Use `gopsutil/v3/host.Info().Platform` to detect the Linux distro and include it in the version string (`distro.go`).
- [x] Agent: Update `main.go` to call `socket.DetectDistro()` instead of `runtime.GOOS`.
- [x] Agent: Ensure version parameter is URL-escaped since it now includes distro info with special chars.
- [x] Backend: Select `version` column in `ListMonitorNodes`.
- [x] Backend: Include `version` in `monitorNodeListItem` JSON response.
- [x] Frontend: Add `version` to TypeScript interfaces (`MonitorNodeApiItem`, `MonitorNode`, `MonitorViewProps`).
- [x] Frontend: Create `distro-icon.tsx` component with SVG logos for Ubuntu, Debian, CentOS/Rocky/Alma, Alpine, Fedora, Arch/Manjaro, and a default Linux (Tux) fallback.
- [x] Frontend: Use `DistroIcon` in `ServerCard` (card view) and list view name column with branded colors per distro.
- [x] All three projects compile cleanly (`go build`, `tsc --noEmit`).
+49
View File
@@ -0,0 +1,49 @@
# Plan 062: Commit, PR, Merge and Tag
## Overview
This plan outlines the steps to commit all changes, create a PR, merge it, and then publish a new tag for the FLVX project.
## Checklist
- [ ] Check current git status for any unexpected changes
- [ ] Create a feature branch `feat-node-os-logo-release`
- [ ] Stage and commit all modifications and untracked files
- [ ] Push the feature branch to origin
- [ ] Create a Pull Request (PR) from the feature branch to `main`
- [ ] Merge the PR to `main`
- [ ] Update `AGENTS.md` with the new tag and commit hash
- [ ] Create and push new tag `2.1.9-beta14`
## Detailed Steps
### 1. Create Feature Branch
```bash
git checkout -b feat-node-os-logo-release
```
### 2. Commit all changes
Add all modified and untracked files:
```bash
git add .
git commit -m "feat: node OS logo support, UI rate overlap fix and tunnel monitoring updates"
```
### 3. Push and PR
Push to `origin`:
```bash
git push origin feat-node-os-logo-release
```
Create PR via `gh pr create` if possible.
### 4. Merge to Main
```bash
git checkout main
git merge feat-node-os-logo-release
git push origin main
```
### 5. Create Tag
Increment the current tag `2.1.9-beta13` to `2.1.9-beta14`.
```bash
git tag 2.1.9-beta14
git push origin 2.1.9-beta14
```
+12
View File
@@ -0,0 +1,12 @@
# Plan 063: Release 2.1.9-rc6
Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc6`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc6-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc6`.
+12
View File
@@ -0,0 +1,12 @@
# Plan 064: Release 2.1.9-rc7
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
## Tasks
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
- [x] Commit all changes to branch `chore/rc7-bump`.
- [x] Push branch to remote.
- [x] Create Pull Request using `gh`.
- [x] Merge Pull Request using `gh`.
- [x] Create and push tag `2.1.9-rc7`.
@@ -0,0 +1,11 @@
# Fix Node Metrics PostgreSQL Type Encoding
## Objective
Fix the PostgreSQL type encoding error (`failed to encode args[0]: unable to encode 5 into text format for text (OID 25)`) and `integer out of range` error when querying node metrics for time ranges greater than 1 hour.
## Tasks
- [x] Identify the problematic downsampled SQL aggregation in `GetNodeMetrics`.
- [x] Fix the `? AS node_id` placeholder which confused PostgreSQL's type inference by directly embedding the `nodeID` using `fmt.Sprintf("%d AS node_id")`.
- [x] Change all `CAST(X AS INTEGER)` to `CAST(X AS BIGINT)` to prevent 32-bit integer overflow on Unix millisecond timestamps in PostgreSQL.
- [x] Verify the build and tests pass.
- [ ] Commit all changes, create a new branch, push, create a Pull Request, merge the PR into `main`, and publish a new tag `2.1.9-rc9`.
+11
View File
@@ -0,0 +1,11 @@
# 066 - Issue 349 IPv6 Entry Format
## Goal
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
## Checklist
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
- [x] 运行相关测试并确认通过。
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
@@ -0,0 +1,22 @@
# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes
**Issue:** https://github.com/Sagit-chu/flvx/issues/342
## Problem
When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database.
## Changes Required
### Backend
- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes:
- **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node.
- **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old".
- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially.
- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline.
### Frontend
- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@flvx/skill-api",
"version": "2.1.5",
"version": "2.1.9-rc4",
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
"keywords": [
"opencode",
+1
View File
@@ -427,6 +427,7 @@ export const getNodeMetrics = (
return Network.get<NodeMetricApiItem[]>(
`/monitor/nodes/${nodeId}/metrics`,
params,
{ timeout: 60_000 },
);
};
+1
View File
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
inx: number;
name: string;
status: number;
version?: string;
updatedTime: number;
}
File diff suppressed because one or more lines are too long
+16
View File
@@ -124,6 +124,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
type: "switch",
},
{
key: "monitor_tunnel_quality_enabled",
label: "实时隧道质量检测",
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
type: "switch",
},
{
key: "captcha_enabled",
label: "启用验证码",
@@ -175,6 +181,7 @@ const getInitialConfigs = (): Record<string, string> => {
"cloudflare_site_key",
"cloudflare_secret_key",
"forward_compact_mode",
"monitor_tunnel_quality_enabled",
"ip",
"panel_domain",
"app_logo",
@@ -407,6 +414,15 @@ export default function ConfigPage() {
detail: { changedKeys },
}),
);
// 如果隧道质量检测开关变更,通知 tunnel-monitor-view
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
window.dispatchEvent(
new CustomEvent("monitorTunnelQualityEnabledChanged", {
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
}),
);
}
} else {
toast.error("保存配置失败: " + response.msg);
}
+2
View File
@@ -15,6 +15,7 @@ type MonitorNode = {
id: number;
name: string;
connectionStatus: "online" | "offline";
version?: string;
};
type MonitorTab = "nodes" | "tunnels";
@@ -73,6 +74,7 @@ export default function MonitorPage() {
id: Number(n.id),
name: String(n.name ?? ""),
connectionStatus: n.status === 1 ? "online" : "offline",
version: n.version,
}));
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
+214 -182
View File
@@ -6,7 +6,7 @@ import type {
ServiceMonitorLimitsApiData,
} from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
LineChart,
Line,
@@ -27,9 +27,13 @@ import {
Server,
Clock,
ArrowLeft,
ArrowUp,
ArrowDown,
Eye,
} from "lucide-react";
import toast from "react-hot-toast";
import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon";
import {
getNodeMetrics,
@@ -73,7 +77,7 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps {
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
nodeMap: Map<number, { id: number; name: string; connectionStatus: string; version?: string }>;
viewMode?: "list" | "grid";
}
@@ -165,6 +169,8 @@ const getColorByUsage = (usage?: number) => {
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
const isOnline = node.connectionStatus === "online";
const distro = parseDistroFromVersion(node.version);
const distroColor = getDistroColor(distro);
return (
<Card
@@ -181,7 +187,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
<div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-0">
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
<DistroIcon distro={distro} className="w-5 h-5" style={{ color: isOnline ? distroColor : undefined }} />
</div>
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
</div>
@@ -263,6 +269,143 @@ type MetricType =
const METRICS_MAX_ROWS = 5000;
/* ─── Memoized Node Metrics Chart sub-component ─────────────────── */
interface NodeMetricsChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
activeMetricType: MetricType;
onMetricTypeChange: (t: MetricType) => void;
loading: boolean;
error: string | null;
truncated: boolean;
maxRows: number;
data: Array<Record<string, unknown>>;
nodeId: number | null;
onRefresh: (id: number) => void;
}
const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
];
const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
rangeMs, onRangeChange, activeMetricType, onMetricTypeChange,
loading, error, truncated, maxRows, data, nodeId, onRefresh,
}: NodeMetricsChartCardProps) {
const chartConfig = (() => {
switch (activeMetricType) {
case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" };
case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" };
case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" };
case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" };
case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" };
case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" };
}
})();
const yAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`;
case "load": return n.toFixed(1);
case "connections": return String(Math.round(n));
}
};
const tooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network": return formatBytesPerSecond(n);
case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`;
case "load": return n.toFixed(2);
case "connections": return String(Math.round(n));
}
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(rangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onRangeChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={loading} size="sm" variant="flat" onPress={() => nodeId && onRefresh(nodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{METRIC_TYPE_BUTTONS.map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => onMetricTypeChange(item.key)}
>
{item.label}
</Button>
))}
</div>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={yAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={tooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{truncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
);
});
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
checkerScanIntervalSec: 1,
minIntervalSec: 1,
@@ -469,8 +612,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return;
}
setMetricsTruncated(false);
setMetricsError(response.msg || "加载指标失败");
if (!silent) toast.error(response.msg || "加载指标失败");
const msg = response.msg || "加载指标失败";
const isTimeout = msg.toLowerCase().includes("timeout");
const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg;
setMetricsError(friendlyMsg);
if (!silent) toast.error(friendlyMsg);
} catch {
setMetricsTruncated(false);
if (!silent) setMetricsError("加载指标失败");
@@ -623,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const timer = window.setInterval(() => {
void loadServiceMonitors({ silent: true });
void loadLatestMonitorResults();
}, 1_000);
}, 5_000);
return () => window.clearInterval(timer);
}, [loadLatestMonitorResults, loadServiceMonitors]);
@@ -648,13 +794,27 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
void loadResultsForModal();
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
// Auto-load results for the resolved default monitor when entering detail view
useEffect(() => {
if (!detailNodeId) return;
if (activeServiceMonitorId) return; // user already selected one
// Find the first monitor belonging to this node (or panel-level)
const firstMonitor = serviceMonitors.find(
(m) => m.nodeId === detailNodeId || m.nodeId === 0,
);
if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) {
void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [detailNodeId, serviceMonitors]);
// Reload results for the active service monitor chart when time range changes
useEffect(() => {
if (!activeServiceMonitorId) return;
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
const chartData = metrics.map((m) => ({
const chartData = useMemo(() => metrics.map((m) => ({
time: formatTimestamp(m.timestamp, metricsRangeMs),
cpu: m.cpuUsage,
memory: m.memoryUsage,
@@ -666,52 +826,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
load15: m.load15,
tcp: m.tcpConns,
udp: m.udpConns,
}));
const getChartConfig = () => {
switch (activeMetricType) {
case "cpu":
return {
lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }],
yAxisLabel: "使用率 (%)",
};
case "memory":
return {
lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }],
yAxisLabel: "使用率 (%)",
};
case "disk":
return {
lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }],
yAxisLabel: "使用率 (%)",
};
case "network":
return {
lines: [
{ dataKey: "netIn", color: "#10b981", name: "入站速度" },
{ dataKey: "netOut", color: "#ef4444", name: "出站速度" },
],
yAxisLabel: "速度 (bytes/s)",
};
case "load":
return {
lines: [
{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" },
{ dataKey: "load5", color: "#8b5cf6", name: "负载 5m" },
{ dataKey: "load15", color: "#f59e0b", name: "负载 15m" },
],
yAxisLabel: "负载值",
};
case "connections":
return {
lines: [
{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" },
{ dataKey: "udp", color: "#10b981", name: "UDP 连接" },
],
yAxisLabel: "连接数",
};
}
};
})), [metrics, metricsRangeMs]);
@@ -922,6 +1037,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
);
// Backend batches DB writes every 30s, but latest API reads from in-memory cache.
// The cache timestamp reflects the real check time (every ~1s), so stale detection
// should still allow for the batch report interval + scan jitter.
const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval
const isResultStale = useCallback(
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
if (monitor.enabled !== 1) {
@@ -932,8 +1052,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
// Budget = batch report interval + one check interval + scan jitter + grace
const budgetMs =
intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
return Date.now() - latestResult.timestamp > budgetMs;
},
@@ -973,46 +1094,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
return { disabled, ok, fail, unknown, stale };
}, [getLatestResult, isResultStale, serviceMonitors]);
const chartConfig = getChartConfig();
const nodeYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(0)}%`;
case "load":
return n.toFixed(1);
case "connections":
return String(Math.round(n));
}
};
const nodeTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
switch (activeMetricType) {
case "network":
return formatBytesPerSecond(n);
case "cpu":
case "memory":
case "disk":
return `${n.toFixed(1)}%`;
case "load":
return n.toFixed(2);
case "connections":
return String(Math.round(n));
}
};
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
@@ -1104,25 +1185,40 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
<div className="flex items-center gap-2">
<DistroIcon distro={parseDistroFromVersion(node.version)} className="w-4 h-4 flex-shrink-0" style={{ color: isOnline ? getDistroColor(parseDistroFromVersion(node.version)) : undefined }} />
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-success-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-success-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-success-50 dark:bg-success-500/10 text-success-500">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
<div className="flex items-center gap-1 font-mono text-primary-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
<div className="flex items-center gap-1.5 font-mono text-primary-500">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-500">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
<div className="flex items-center gap-1.5 font-mono text-default-600">
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span>
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
</div>
</div>
</div>
</TableCell>
@@ -1238,83 +1334,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
)}
{/* Node metrics chart */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">节点指标图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-36"
selectedKeys={[String(metricsRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setMetricsRangeMs(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
<Button isLoading={metricsLoading} size="sm" variant="flat" onPress={() => selectedNodeId && loadMetrics(selectedNodeId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody className="space-y-4">
<div className="flex flex-wrap gap-2">
{([
{ key: "cpu", label: "CPU" },
{ key: "memory", label: "内存" },
{ key: "disk", label: "磁盘" },
{ key: "network", label: "网络" },
{ key: "load", label: "负载" },
{ key: "connections", label: "连接" },
] as { key: MetricType; label: string }[]).map((item) => (
<Button
key={item.key}
color={activeMetricType === item.key ? "primary" : "default"}
size="sm"
variant={activeMetricType === item.key ? "solid" : "flat"}
onPress={() => setActiveMetricType(item.key)}
>
{item.label}
</Button>
))}
</div>
{metricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : metricsError ? (
<div className="text-center py-8 text-danger text-sm">{metricsError}</div>
) : metrics.length > 0 ? (
<>
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" fontSize={12} />
<YAxis fontSize={12} tickFormatter={nodeYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
labelStyle={{ color: "#fff" }}
formatter={nodeTooltipFormatter}
/>
{chartConfig.lines.map((line) => (
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
))}
</LineChart>
</ResponsiveContainer>
</div>
{metricsTruncated && (
<div className="text-xs text-default-500">数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。</div>
)}
</>
) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div>
)}
</CardBody>
</Card>
<NodeMetricsChartCard
rangeMs={metricsRangeMs}
onRangeChange={setMetricsRangeMs}
activeMetricType={activeMetricType}
onMetricTypeChange={setActiveMetricType}
loading={metricsLoading}
error={metricsError}
truncated={metricsTruncated}
maxRows={METRICS_MAX_ROWS}
data={chartData}
nodeId={selectedNodeId}
onRefresh={loadMetrics}
/>
{/* Service monitors chart – same style as node metrics */}
@@ -4,7 +4,7 @@ import type {
TunnelQualityApiItem,
} from "@/api/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
LineChart,
Line,
@@ -31,6 +31,7 @@ import {
getTunnelMetrics,
getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
getConfigByName,
} from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button";
@@ -51,6 +52,9 @@ interface TunnelMonitorViewProps {
}
const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged";
const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts);
@@ -72,17 +76,6 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
});
};
const formatBytes = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
/** Render a colored latency value with appropriate visual cue */
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
@@ -99,7 +92,6 @@ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
}
/** Animated pulse dot for live status */
function LiveDot() {
return (
<span className="relative flex h-2 w-2">
@@ -182,6 +174,159 @@ function UptimeHistoryBar({
);
}
const TIME_RANGE_OPTIONS = [
{ key: String(15 * 60 * 1000), label: "15分钟" },
{ key: String(60 * 60 * 1000), label: "1小时" },
{ key: String(6 * 60 * 60 * 1000), label: "6小时" },
{ key: String(24 * 60 * 60 * 1000), label: "24小时" },
];
function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
return (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
{TIME_RANGE_OPTIONS.map((opt) => (
<SelectItem key={opt.key}>{opt.label}</SelectItem>
))}
</Select>
);
}
interface QualityChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: QualityChartCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
<Line connectNulls dataKey="entryToExit" dot={false} name="entryToExit" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line connectNulls dataKey="exitToBing" dot={false} name="exitToBing" stroke="#3b82f6" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
);
});
interface TrafficChartCardProps {
rangeMs: number;
onRangeChange: (v: number) => void;
loading: boolean;
error: string | null;
data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
tunnelId: number;
onRefresh: (id: number) => void;
}
const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
}: TrafficChartCardProps) {
const yFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{loading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={yFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
);
});
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false);
@@ -194,6 +339,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null);
const [monitorTunnelQualityEnabled, setMonitorTunnelQualityEnabled] =
useState(true);
// Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
@@ -241,9 +388,25 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}
}, []);
const loadMonitorTunnelQualityEnabled = useCallback(async () => {
try {
const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
);
setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string"
? response.data.value === "true"
: true,
);
} catch {
setMonitorTunnelQualityEnabled(true);
}
}, []);
useEffect(() => {
void loadTunnels();
}, [loadTunnels]);
void loadMonitorTunnelQualityEnabled();
}, [loadMonitorTunnelQualityEnabled, loadTunnels]);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -253,7 +416,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer);
}, [loadTunnels]);
// --- Initial history load ---
useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled);
if (!enabled) {
setQualityLoading(false);
}
} else {
void loadMonitorTunnelQualityEnabled();
}
};
window.addEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
return () => {
window.removeEventListener(
MONITOR_TUNNEL_QUALITY_ENABLED_EVENT,
handleMonitorTunnelQualityEnabledChanged as EventListener,
);
};
}, [loadMonitorTunnelQualityEnabled]);
useEffect(() => {
if (tunnels.length > 0 && !initialHistoryFetched.current) {
initialHistoryFetched.current = true;
@@ -326,10 +514,22 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
}, []);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
return;
}
void loadQuality();
}, [loadQuality]);
}, [loadQuality, monitorTunnelQualityEnabled]);
useEffect(() => {
if (!monitorTunnelQualityEnabled) {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
return;
}
qualityTimerRef.current = window.setInterval(() => {
void loadQuality({ silent: true });
}, QUALITY_POLL_INTERVAL);
@@ -337,9 +537,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => {
if (qualityTimerRef.current) {
window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null;
}
};
}, [loadQuality]);
}, [loadQuality, monitorTunnelQualityEnabled]);
// --- Load quality history for detail chart ---
const loadQualityHistory = useCallback(
@@ -403,50 +604,57 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => {
if (detailTunnelId) {
void loadQualityHistory(detailTunnelId);
void loadTunnelMetrics(detailTunnelId);
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId);
}
}
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Auto-refresh detail charts
useEffect(() => {
if (!detailTunnelId) return;
const timer = window.setInterval(() => {
void loadQualityHistory(detailTunnelId, { silent: true });
if (monitorTunnelQualityEnabled) {
void loadQualityHistory(detailTunnelId, { silent: true });
}
void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000);
return () => window.clearInterval(timer);
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
}, [
detailTunnelId,
loadQualityHistory,
loadTunnelMetrics,
monitorTunnelQualityEnabled,
]);
// Chart data for quality history
const qualityChartData = qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
}));
// Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo(
() => qualityHistory.map((q) => ({
time: formatTimestamp(q.timestamp, qualityRangeMs),
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
entryToExitLoss: q.entryToExitLoss,
exitToBingLoss: q.exitToBingLoss,
})),
[qualityHistory, qualityRangeMs],
);
// Chart data for traffic metrics
const tunnelChartData = tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
}));
const tunnelYAxisTickFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "";
return formatBytes(n);
};
const tunnelTooltipFormatter = (value: unknown) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
return formatBytes(n);
};
const tunnelChartData = useMemo(
() => tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn,
bytesOut: m.bytesOut,
connections: m.connections,
})),
[tunnelMetrics, tunnelRangeMs],
);
const detailTunnel = detailTunnelId != null
? tunnels.find((t) => t.id === detailTunnelId)
@@ -468,27 +676,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]);
/** Shared time range Select component */
const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => (
<Select
className="w-36"
selectedKeys={[String(value)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v);
}}
>
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
);
// =====================
// RENDER
// =====================
if (accessDenied) {
return (
<Card>
@@ -506,7 +693,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
);
}
// ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) {
const quality = qualityMap[detailTunnelId];
@@ -571,8 +757,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{/* Auto-probe status */}
<div className="flex items-center gap-2 text-xs text-default-500">
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>自动探测中(每秒测试,30秒上报)</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时隧道质量检测已关闭</span>
</>
)}
{quality?.timestamp && (
<span className="text-default-400">
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
@@ -583,123 +778,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)}
</div>
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
<Button
isLoading={qualityHistoryLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{qualityHistoryLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : qualityHistoryError ? (
<div className="text-center py-8 text-danger text-sm">{qualityHistoryError}</div>
) : qualityChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={qualityChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/>
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={(value: unknown, name: string) => {
const n = Number(value);
if (!Number.isFinite(n)) return "-";
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
return [`${n.toFixed(1)}ms`, label];
}}
/>
{/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard
rangeMs={qualityRangeMs}
onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
tunnelId={detailTunnelId}
onRefresh={loadQualityHistory}
/>
<Line
connectNulls
dataKey="entryToExit"
dot={false}
name="entryToExit"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
connectNulls
dataKey="exitToBing"
dot={false}
name="exitToBing"
stroke="#3b82f6"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
)}
</CardBody>
</Card>
{/* ====== Traffic Chart (unchanged) ====== */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2">
<TimeRangeSelect value={tunnelRangeMs} onChange={setTunnelRangeMs} />
<Button
isLoading={tunnelMetricsLoading}
size="sm"
variant="flat"
onPress={() => detailTunnelId && loadTunnelMetrics(detailTunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新
</Button>
</div>
</CardHeader>
<CardBody>
{tunnelMetricsLoading ? (
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : tunnelMetricsError ? (
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
) : tunnelChartData.length > 0 ? (
<div className="h-64">
<ResponsiveContainer height="100%" width="100%">
<LineChart data={tunnelChartData}>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
<Tooltip
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
labelStyle={{ color: "#fff" }}
formatter={tunnelTooltipFormatter}
/>
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="text-center py-8 text-default-500">暂无流量数据</div>
)}
</CardBody>
</Card>
{/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard
rangeMs={tunnelRangeMs}
onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
tunnelId={detailTunnelId}
onRefresh={loadTunnelMetrics}
/>
</div>
);
}
@@ -709,12 +808,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{lastQualityUpdate && (
{lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
{monitorTunnelQualityEnabled ? (
<>
<LiveDot />
<span>每秒探测 · 更新于 {lastQualityUpdate}</span>
</>
) : (
<>
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭 · 最近更新于 {lastQualityUpdate}</span>
</>
)}
</div>
)}
) : !monitorTunnelQualityEnabled ? (
<div className="flex items-center gap-1.5 text-xs text-default-500">
<WifiOff className="w-3.5 h-3.5 text-warning" />
<span>实时质量检测已关闭</span>
</div>
) : null}
<div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -786,11 +899,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1">
<LiveDot />
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3 h-3 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-[11px] text-default-400">等待探测...</span>
<span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
</span>
)}
</div>
</CardBody>
@@ -844,11 +963,17 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableCell>
{quality?.timestamp ? (
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
<LiveDot />
{monitorTunnelQualityEnabled ? (
<LiveDot />
) : (
<WifiOff className="w-3.5 h-3.5 text-warning" />
)}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span>
) : (
<span className="text-xs text-default-400">-</span>
<span className="text-xs text-default-400">
{monitorTunnelQualityEnabled ? "-" : "实时检测已关闭"}
</span>
)}
</TableCell>
</TableRow>
+34 -30
View File
@@ -32,6 +32,9 @@ interface PanelAddress {
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
typeof value === "string" ? value === "true" : defaultValue;
export const SettingsPage = () => {
const navigate = useNavigate();
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
@@ -50,9 +53,16 @@ export const SettingsPage = () => {
setPanelAddresses(newAddress);
};
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表
const loadPanelAddresses = async () => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
getPanelAddresses();
};
@@ -72,7 +82,6 @@ export const SettingsPage = () => {
return;
}
(window as any).setPanelAddresses = setPanelAddressesFunc;
savePanelAddress(newName.trim(), newAddress.trim());
setNewName("");
setNewAddress("");
@@ -81,14 +90,12 @@ export const SettingsPage = () => {
// 设置当前面板地址
const setCurrentPanel = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
const handleDeletePanelAddress = async (name: string) => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
deletePanelAddress(name);
reinitializeBaseURL();
toast.success("删除成功");
@@ -103,12 +110,7 @@ export const SettingsPage = () => {
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
const enabled =
res.code === 0 &&
typeof res.data?.value === "string" &&
res.data.value === "true";
setForwardCompactMode(enabled);
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
} catch {
setForwardCompactMode(false);
}
@@ -216,28 +218,30 @@ export const SettingsPage = () => {
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</CardBody>
</Card>
+1 -1
View File
@@ -418,7 +418,7 @@ export default function TunnelPage() {
// 表单验证
const validateForm = (): boolean => {
const newErrors = validateTunnelForm(form, nodes);
const newErrors = validateTunnelForm(form, nodes, isEdit);
setErrors(newErrors);
+12 -7
View File
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
export const validateTunnelForm = (
form: TunnelFormInput,
nodes: TunnelNodeInput[],
isEdit = false,
): Record<string, string> => {
const errors: Record<string, string> = {};
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
if (!form.inNodeId || form.inNodeId.length === 0) {
errors.inNodeId = "请至少选择一个入口节点";
} else {
} else if (!isEdit) {
// Only enforce online check for new tunnels. During edit the backend
// allows existing offline nodes (user may be removing them).
const offlineInNodes = form.inNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
@@ -64,14 +67,16 @@ export const validateTunnelForm = (
if (!form.outNodeId || form.outNodeId.length === 0) {
errors.outNodeId = "请至少选择一个出口节点";
} else {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
if (!isEdit) {
const offlineOutNodes = form.outNodeId.filter((item) => {
const node = nodes.find((n) => n.id === item.nodeId);
return node && node.status !== 1;
});
return node && node.status !== 1;
});
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
if (offlineOutNodes.length > 0) {
errors.outNodeId = "所有出口节点必须在线";
}
}
const inNodeIds = form.inNodeId.map((item) => item.nodeId);