From e08480f609afe4d5085b2eba32ec2a7d595d4f1e Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 7 Jul 2026 13:16:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=95=B4=E5=90=88=E9=85=8D=E7=BD=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/i18n/en.toml | 22 +++++++-- internal/i18n/zh-Hans.toml | 22 +++++++-- internal/stats/stats.go | 80 ++++++++++++++++++++++++++++--- internal/ui/app.go | 79 +++++++++++++++--------------- internal/ui/profile.go | 98 +++++++++++++++++++++++++++++++++++++- internal/ui/view.go | 69 ++++++++++++++++++++------- internal/vpn/session.go | 66 +++++++++++++++++++++++-- 7 files changed, 359 insertions(+), 77 deletions(-) diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index 9ae059b..aa701bc 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -24,10 +24,18 @@ Ip6None = "IPv6: —" IpNone = "IP: —" UptimeLabel = "Uptime: {{.uptime}}" UptimeNone = "Uptime: —" -RxLabel = "↓ {{.bytes}}" -TxLabel = "↑ {{.bytes}}" -RxZero = "↓ 0 B" -TxZero = "↑ 0 B" +RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}" +TxV4Label = "↑ {{.bytes}} {{.speed}}" +RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}" +TxV6Label = "↑ {{.bytes}} {{.speed}}" +RxTotalLabel = "Total ↓ {{.bytes}} {{.speed}}" +TxTotalLabel = "↑ {{.bytes}} {{.speed}}" +RxV4Zero = "IPv4 ↓ 0 B 0 bps" +TxV4Zero = "↑ 0 B 0 bps" +RxV6Zero = "IPv6 ↓ 0 B 0 bps" +TxV6Zero = "↑ 0 B 0 bps" +RxTotalZero = "Total ↓ 0 B 0 bps" +TxTotalZero = "↑ 0 B 0 bps" DlgNoProfileTitle = "No Profile" DlgNoProfileEditMsg = "Select a profile to edit." @@ -50,6 +58,12 @@ BtnResetDB = "Reset Database" DlgResetDBTitle = "Reset Database" DlgResetDBMsg = "Delete all profiles and connection logs? This cannot be undone." +BtnProfileList = "Profile List" +ProfileListTitle = "Profile List" +DlgNoListSelectTitle = "No Selection" +DlgNoListSelectEditMsg = "Select a profile in the list to edit." +DlgNoListSelectDeleteMsg = "Select a profile in the list to delete." + TrayShowWindow = "Show Window" TrayConnect = "Connect" TrayDisconnect = "Disconnect" diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index dd40b33..8f82a97 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -24,10 +24,18 @@ Ip6None = "IPv6: —" IpNone = "IP: —" UptimeLabel = "运行时长: {{.uptime}}" UptimeNone = "运行时长: —" -RxLabel = "↓ {{.bytes}}" -TxLabel = "↑ {{.bytes}}" -RxZero = "↓ 0 B" -TxZero = "↑ 0 B" +RxV4Label = "IPv4 ↓ {{.bytes}} {{.speed}}" +TxV4Label = "↑ {{.bytes}} {{.speed}}" +RxV6Label = "IPv6 ↓ {{.bytes}} {{.speed}}" +TxV6Label = "↑ {{.bytes}} {{.speed}}" +RxTotalLabel = "合计 ↓ {{.bytes}} {{.speed}}" +TxTotalLabel = "↑ {{.bytes}} {{.speed}}" +RxV4Zero = "IPv4 ↓ 0 B 0 bps" +TxV4Zero = "↑ 0 B 0 bps" +RxV6Zero = "IPv6 ↓ 0 B 0 bps" +TxV6Zero = "↑ 0 B 0 bps" +RxTotalZero = "合计 ↓ 0 B 0 bps" +TxTotalZero = "↑ 0 B 0 bps" DlgNoProfileTitle = "无配置" DlgNoProfileEditMsg = "请选择要编辑的配置。" @@ -50,6 +58,12 @@ BtnResetDB = "重置数据库" DlgResetDBTitle = "重置数据库" DlgResetDBMsg = "删除所有配置和连接记录?此操作无法撤销。" +BtnProfileList = "配置列表" +ProfileListTitle = "配置列表" +DlgNoListSelectTitle = "无选择" +DlgNoListSelectEditMsg = "请在列表中选择一个配置。" +DlgNoListSelectDeleteMsg = "请在列表中选择一个配置。" + TrayShowWindow = "显示窗口" TrayConnect = "连接" TrayDisconnect = "断开连接" diff --git a/internal/stats/stats.go b/internal/stats/stats.go index e07676e..1d697dc 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -19,15 +19,57 @@ const ( // Stats holds live session statistics. Counters are atomic for // lock-free reads from the UI/IPC layer. +// +// Counters are split by IP address family (v4/v6) at the recording +// site via AddRx/AddTx, which inspect the IP version nibble. The +// combined RxBytes/TxBytes are derived as the sum of the per-family +// counters in Snapshot, so callers that only need the total still +// work. type Stats struct { - RxBytes atomic.Int64 - TxBytes atomic.Int64 + RxBytesV4 atomic.Int64 + RxBytesV6 atomic.Int64 + TxBytesV4 atomic.Int64 + TxBytesV6 atomic.Int64 ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected state atomic.Value // State assignedIP atomic.Value // string (IPv4) assignedIP6 atomic.Value // string (IPv6, may be empty) } +// AddRx records a downloaded packet (WebSocket → TUN) of length n, +// routing the bytes to the v4 or v6 counter based on the IP version +// nibble of the packet. Packets too short to contain a version byte +// or with an unknown version are ignored. +func (s *Stats) AddRx(p []byte) { + if len(p) == 0 { + return + } + n := int64(len(p)) + switch p[0] >> 4 { + case 4: + s.RxBytesV4.Add(n) + case 6: + s.RxBytesV6.Add(n) + } +} + +// AddTx records an uploaded packet (TUN → WebSocket) of length n, +// routing the bytes to the v4 or v6 counter based on the IP version +// nibble of the packet. Packets too short to contain a version byte +// or with an unknown version are ignored. +func (s *Stats) AddTx(p []byte) { + if len(p) == 0 { + return + } + n := int64(len(p)) + switch p[0] >> 4 { + case 4: + s.TxBytesV4.Add(n) + case 6: + s.TxBytesV6.Add(n) + } +} + // New creates a Stats instance initialised to the disconnected state. func New() *Stats { s := &Stats{} @@ -67,9 +109,27 @@ func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) } func (s *Stats) AssignedIP6() string { return s.assignedIP6.Load().(string) } // Snapshot returns a point-in-time copy of all counters. +// +// Per-family byte counters are read directly. The combined RxBytes/ +// TxBytes are derived as v4+v6. Speed fields (RxSpeedV4 etc.) are +// left zero here; the SessionManager fills them in by computing +// per-tick deltas with EWMA smoothing. type Snapshot struct { - RxBytes int64 - TxBytes int64 + RxBytesV4 int64 + RxBytesV6 int64 + TxBytesV4 int64 + TxBytesV6 int64 + RxBytes int64 // combined (v4+v6) + TxBytes int64 // combined (v4+v6) + + // Speeds in bytes/sec (EWMA-smoothed). The UI converts to bits/sec. + RxSpeedV4 int64 + TxSpeedV4 int64 + RxSpeedV6 int64 + TxSpeedV6 int64 + RxSpeed int64 // combined + TxSpeed int64 // combined + ConnectedAt time.Time AssignedIP string AssignedIP6 string @@ -79,9 +139,17 @@ type Snapshot struct { // Snapshot returns a point-in-time copy of the statistics. func (s *Stats) Snapshot() Snapshot { + rxv4 := s.RxBytesV4.Load() + rxv6 := s.RxBytesV6.Load() + txv4 := s.TxBytesV4.Load() + txv6 := s.TxBytesV6.Load() snap := Snapshot{ - RxBytes: s.RxBytes.Load(), - TxBytes: s.TxBytes.Load(), + RxBytesV4: rxv4, + RxBytesV6: rxv6, + TxBytesV4: txv4, + TxBytesV6: txv6, + RxBytes: rxv4 + rxv6, + TxBytes: txv4 + txv6, AssignedIP: s.AssignedIP(), AssignedIP6: s.AssignedIP6(), State: s.State(), diff --git a/internal/ui/app.go b/internal/ui/app.go index 9eb461c..2adc280 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,14 +27,23 @@ type App struct { window fyne.Window profileWindow fyne.Window + // Profile list window. + profileListWindow fyne.Window + profileList *widget.List + listSelectedIndex int + // UI widgets profileSelect *widget.Select stateLabel *widget.Label ipLabel *widget.Label ip6Label *widget.Label uptimeLabel *widget.Label - rxLabel *widget.Label - txLabel *widget.Label + rxV4Label *widget.Label + txV4Label *widget.Label + rxV6Label *widget.Label + txV6Label *widget.Label + rxTotalLabel *widget.Label + txTotalLabel *widget.Label connectBtn *widget.Button disconnectBtn *widget.Button @@ -71,10 +80,11 @@ func Run() { } a := &App{ - fyneApp: app.NewWithID(paths.BundleID), - db: store, - kc: keychain.New(), - langSetting: cfg.Language, + fyneApp: app.NewWithID(paths.BundleID), + db: store, + kc: keychain.New(), + langSetting: cfg.Language, + listSelectedIndex: -1, } a.window = a.fyneApp.NewWindow(i18n.T("WindowTitle")) @@ -128,6 +138,16 @@ func (a *App) loadProfiles() { a.profileSelect.SetSelected("") a.profileSelect.Refresh() } + a.listSelectedIndex = -1 + a.refreshProfileList() +} + +// refreshProfileList refreshes the profile list widget if the profile +// list window is currently open. +func (a *App) refreshProfileList() { + if a.profileList != nil { + a.profileList.Refresh() + } } // profileNames returns the names of all loaded profiles. @@ -154,39 +174,6 @@ func (a *App) onAddProfile() { a.showProfileDialog(nil) } -// onEditProfile shows a dialog to edit the current profile. -func (a *App) onEditProfile() { - if a.currentProfile == nil { - dialog.NewCustom(i18n.T("DlgNoProfileTitle"), i18n.T("BtnOK"), - widget.NewLabel(i18n.T("DlgNoProfileEditMsg")), a.window).Show() - return - } - p := *a.currentProfile - a.showProfileDialog(&p) -} - -// onDeleteProfile deletes the current profile after confirmation. -func (a *App) onDeleteProfile() { - if a.currentProfile == nil { - return - } - name := a.currentProfile.Name - dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"), - i18n.T("BtnDelete"), i18n.T("BtnCancel"), - widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": name})), - func(ok bool) { - if !ok { - return - } - if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil { - showError(i18n.T("DlgError"), err.Error(), a.window) - return - } - _ = a.kc.DeleteAll(name) - a.loadProfiles() - }, a.window).Show() -} - // onResetDB deletes the SQLite database file after confirmation, // then re-creates it. All profiles, credentials, and logs are lost. func (a *App) onResetDB() { @@ -235,8 +222,12 @@ func (a *App) onResetDB() { a.ipLabel.SetText(i18n.T("IpNone")) a.ip6Label.SetText(i18n.T("Ip6None")) a.uptimeLabel.SetText(i18n.T("UptimeNone")) - a.rxLabel.SetText(i18n.T("RxZero")) - a.txLabel.SetText(i18n.T("TxZero")) + a.rxV4Label.SetText(i18n.T("RxV4Zero")) + a.txV4Label.SetText(i18n.T("TxV4Zero")) + a.rxV6Label.SetText(i18n.T("RxV6Zero")) + a.txV6Label.SetText(i18n.T("TxV6Zero")) + a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) + a.txTotalLabel.SetText(i18n.T("TxTotalZero")) a.connectBtn.Enable() a.disconnectBtn.Disable() }, a.window).Show() @@ -279,6 +270,12 @@ func (a *App) rebuildUI() { selectedName = a.currentProfile.Name } + // Close the profile list window if open; it holds cached strings + // and will be recreated with the new language on next open. + if a.profileListWindow != nil { + a.profileListWindow.Close() + } + // Rebuild window content (creates fresh widgets with new strings). a.window.SetTitle(i18n.T("WindowTitle")) a.window.SetContent(a.buildMainWindow()) diff --git a/internal/ui/profile.go b/internal/ui/profile.go index d6e558d..65c9196 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "strconv" "lmvpn/internal/i18n" @@ -8,6 +9,7 @@ import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" "fyne.io/fyne/v2/widget" ) @@ -18,7 +20,7 @@ var ( authCodes = []string{string(model.AuthModeBoth), string(model.AuthModeJWT), string(model.AuthModePassword)} routeCodes = []string{string(model.RoutingFull), string(model.RoutingSplit), string(model.RoutingCustom)} - protoCodes = []string{"wss", "ws"} + protoCodes = []string{"wss", "ws"} ) func authModeLabels() []string { @@ -267,3 +269,97 @@ func parseIntDefault(s string, def int) int { } return n } + +// showProfileListWindow opens a window listing all saved profiles with +// buttons to add, edit, delete, and reset the database. Only one +// instance is allowed; if already open it is brought to the front. +func (a *App) showProfileListWindow() { + if a.profileListWindow != nil { + a.profileListWindow.RequestFocus() + return + } + + a.profileList = widget.NewList( + func() int { return len(a.profiles) }, + func() fyne.CanvasObject { + name := widget.NewLabel("") + name.TextStyle = fyne.TextStyle{Bold: true} + host := widget.NewLabel("") + return container.NewVBox(name, host) + }, + func(id widget.ListItemID, obj fyne.CanvasObject) { + box := obj.(*fyne.Container) + nameLbl := box.Objects[0].(*widget.Label) + hostLbl := box.Objects[1].(*widget.Label) + if id >= 0 && id < len(a.profiles) { + p := a.profiles[id] + nameLbl.SetText(p.Name) + hostLbl.SetText(fmt.Sprintf("%s:%d", p.Host, p.Port)) + } + }, + ) + a.profileList.OnSelected = func(id widget.ListItemID) { + a.listSelectedIndex = id + } + a.profileList.OnUnselected = func(_ widget.ListItemID) { + a.listSelectedIndex = -1 + } + + addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile) + editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfileFromList) + deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfileFromList) + resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB) + + buttons := container.NewGridWithColumns(4, addBtn, editBtn, deleteBtn, resetDBBtn) + + win := a.fyneApp.NewWindow(i18n.T("ProfileListTitle")) + a.profileListWindow = win + win.SetOnClosed(func() { + a.profileListWindow = nil + a.profileList = nil + a.listSelectedIndex = -1 + }) + + win.SetContent(container.NewBorder(nil, buttons, nil, nil, a.profileList)) + win.Resize(fyne.NewSize(460, 400)) + win.Show() +} + +// onEditProfileFromList opens the profile editor for the profile +// selected in the list window. +func (a *App) onEditProfileFromList() { + idx := a.listSelectedIndex + if idx < 0 || idx >= len(a.profiles) { + dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"), + widget.NewLabel(i18n.T("DlgNoListSelectEditMsg")), a.profileListWindow).Show() + return + } + p := a.profiles[idx] + a.showProfileDialog(&p) +} + +// onDeleteProfileFromList deletes the profile selected in the list +// window after confirmation. +func (a *App) onDeleteProfileFromList() { + idx := a.listSelectedIndex + if idx < 0 || idx >= len(a.profiles) { + dialog.NewCustom(i18n.T("DlgNoListSelectTitle"), i18n.T("BtnOK"), + widget.NewLabel(i18n.T("DlgNoListSelectDeleteMsg")), a.profileListWindow).Show() + return + } + p := a.profiles[idx] + dialog.NewCustomConfirm(i18n.T("DlgDeleteProfileTitle"), + i18n.T("BtnDelete"), i18n.T("BtnCancel"), + widget.NewLabel(i18n.T("DlgDeleteProfileMsg", map[string]interface{}{"name": p.Name})), + func(ok bool) { + if !ok { + return + } + if err := a.db.DeleteProfile(p.ID); err != nil { + showError(i18n.T("DlgError"), err.Error(), a.profileListWindow) + return + } + _ = a.kc.DeleteAll(p.Name) + a.loadProfiles() + }, a.profileListWindow).Show() +} diff --git a/internal/ui/view.go b/internal/ui/view.go index b940cb4..174d07a 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -32,15 +32,21 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.ipLabel = widget.NewLabel(i18n.T("IpNone")) a.ip6Label = widget.NewLabel(i18n.T("Ip6None")) a.uptimeLabel = widget.NewLabel(i18n.T("UptimeNone")) - a.rxLabel = widget.NewLabel(i18n.T("RxZero")) - a.txLabel = widget.NewLabel(i18n.T("TxZero")) + a.rxV4Label = widget.NewLabel(i18n.T("RxV4Zero")) + a.txV4Label = widget.NewLabel(i18n.T("TxV4Zero")) + a.rxV6Label = widget.NewLabel(i18n.T("RxV6Zero")) + a.txV6Label = widget.NewLabel(i18n.T("TxV6Zero")) + a.rxTotalLabel = widget.NewLabel(i18n.T("RxTotalZero")) + a.txTotalLabel = widget.NewLabel(i18n.T("TxTotalZero")) statusCard := widget.NewCard(i18n.T("StatusLabel"), "", container.NewVBox( a.stateLabel, a.ipLabel, a.ip6Label, a.uptimeLabel, - container.NewHBox(a.rxLabel, a.txLabel), + container.NewHBox(a.rxV4Label, a.txV4Label), + container.NewHBox(a.rxV6Label, a.txV6Label), + container.NewHBox(a.rxTotalLabel, a.txTotalLabel), )) // Buttons. @@ -49,25 +55,17 @@ func (a *App) buildMainWindow() fyne.CanvasObject { a.disconnectBtn = widget.NewButton(i18n.T("BtnDisconnect"), a.onDisconnect) a.disconnectBtn.Disable() - addBtn := widget.NewButton(i18n.T("BtnAddProfile"), a.onAddProfile) - editBtn := widget.NewButton(i18n.T("BtnEdit"), a.onEditProfile) - deleteBtn := widget.NewButton(i18n.T("BtnDelete"), a.onDeleteProfile) - - resetDBBtn := widget.NewButton(i18n.T("BtnResetDB"), a.onResetDB) + profileListBtn := widget.NewButton(i18n.T("BtnProfileList"), a.showProfileListWindow) buttons := container.NewGridWithColumns(2, a.connectBtn, a.disconnectBtn, ) - profileButtons := container.NewGridWithColumns(3, - addBtn, editBtn, deleteBtn, - ) return container.NewVBox( widget.NewLabel(i18n.T("ProfileLabel")), a.profileSelect, buttons, - profileButtons, - resetDBBtn, + profileListBtn, statusCard, widget.NewLabel(fmt.Sprintf("v%s", Version)), ) @@ -185,8 +183,12 @@ func (a *App) eventLoop() { a.ipLabel.SetText(i18n.T("IpNone")) a.ip6Label.SetText(i18n.T("Ip6None")) a.uptimeLabel.SetText(i18n.T("UptimeNone")) - a.rxLabel.SetText(i18n.T("RxZero")) - a.txLabel.SetText(i18n.T("TxZero")) + a.rxV4Label.SetText(i18n.T("RxV4Zero")) + a.txV4Label.SetText(i18n.T("TxV4Zero")) + a.rxV6Label.SetText(i18n.T("RxV6Zero")) + a.txV6Label.SetText(i18n.T("TxV6Zero")) + a.rxTotalLabel.SetText(i18n.T("RxTotalZero")) + a.txTotalLabel.SetText(i18n.T("TxTotalZero")) a.connectBtn.Enable() a.disconnectBtn.Disable() } @@ -256,8 +258,24 @@ func (a *App) applyStats(s stats.Snapshot) { if s.State == stats.StateConnected { a.stateLabel.SetText(i18n.T("StateConnected")) } - a.rxLabel.SetText(i18n.T("RxLabel", map[string]interface{}{"bytes": formatBytes(s.RxBytes)})) - a.txLabel.SetText(i18n.T("TxLabel", map[string]interface{}{"bytes": formatBytes(s.TxBytes)})) + a.rxV4Label.SetText(i18n.T("RxV4Label", map[string]interface{}{ + "bytes": formatBytes(s.RxBytesV4), "speed": formatSpeed(s.RxSpeedV4), + })) + a.txV4Label.SetText(i18n.T("TxV4Label", map[string]interface{}{ + "bytes": formatBytes(s.TxBytesV4), "speed": formatSpeed(s.TxSpeedV4), + })) + a.rxV6Label.SetText(i18n.T("RxV6Label", map[string]interface{}{ + "bytes": formatBytes(s.RxBytesV6), "speed": formatSpeed(s.RxSpeedV6), + })) + a.txV6Label.SetText(i18n.T("TxV6Label", map[string]interface{}{ + "bytes": formatBytes(s.TxBytesV6), "speed": formatSpeed(s.TxSpeedV6), + })) + a.rxTotalLabel.SetText(i18n.T("RxTotalLabel", map[string]interface{}{ + "bytes": formatBytes(s.RxBytes), "speed": formatSpeed(s.RxSpeed), + })) + a.txTotalLabel.SetText(i18n.T("TxTotalLabel", map[string]interface{}{ + "bytes": formatBytes(s.TxBytes), "speed": formatSpeed(s.TxSpeed), + })) if s.Uptime > 0 { a.uptimeLabel.SetText(i18n.T("UptimeLabel", map[string]interface{}{"uptime": formatDuration(s.Uptime)})) } @@ -277,6 +295,23 @@ func formatBytes(b int64) string { return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) } +// formatSpeed formats a bytes/sec rate as a decimal bits/sec rate +// (kbps / Mbps / Gbps). bps is the byte rate; it is converted to bits +// by multiplying by 8 and scaled by 1000 (decimal, network convention). +func formatSpeed(bps int64) string { + const unit = 1000 + bits := bps * 8 + if bits < unit { + return fmt.Sprintf("%d bps", bits) + } + div, exp := int64(unit), 0 + for n := bits / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %cbps", float64(bits)/float64(div), "kMGTPE"[exp]) +} + // formatDuration formats an uptime duration. func formatDuration(d time.Duration) string { d = d.Round(time.Second) diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 3481848..85edf87 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -51,6 +51,18 @@ type SessionManager struct { dev tun.Device routeMgr *route.Manager conn *transport.Conn + + // EWMA speed smoothing state. Only touched by reportStats (single + // goroutine), so no lock needed. ewma* fields hold the smoothed + // bytes/sec; prev* hold the last snapshot's cumulative counters and + // tick time for delta computation. + ewmaRxV4 float64 + ewmaTxV4 float64 + ewmaRxV6 float64 + ewmaTxV6 float64 + prevSnap stats.Snapshot + prevTick time.Time + speedReady bool } // New creates a SessionManager. The onState callback (if non-nil) is @@ -359,7 +371,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) } return } - sm.stats.TxBytes.Add(int64(n)) + sm.stats.AddTx(buf[:n]) } }() @@ -386,7 +398,7 @@ func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) conn.Close() return } - sm.stats.RxBytes.Add(int64(len(data))) + sm.stats.AddRx(data) } }() @@ -446,9 +458,15 @@ func (sm *SessionManager) setState(s stats.State) { } // reportStats periodically calls the onStats callback while connected. +// On each tick it derives per-family speeds (bytes/sec) from the +// delta between the current and previous cumulative counters, then +// applies EWMA smoothing (0.7 old + 0.3 new) so the displayed rates +// don't jitter. The combined speeds are the sum of the per-family +// smoothed values. func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) { ticker := time.NewTicker(time.Second) defer ticker.Stop() + const ewmaAlpha = 0.3 for { select { case <-done: @@ -456,9 +474,49 @@ func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) case <-ctx.Done(): return case <-ticker.C: - if sm.onStats != nil { - sm.onStats(sm.stats.Snapshot()) + if sm.onStats == nil { + continue } + snap := sm.stats.Snapshot() + now := time.Now() + if sm.speedReady { + elapsed := now.Sub(sm.prevTick).Seconds() + if elapsed <= 0 { + elapsed = 1 + } + // Per-second deltas (bytes/sec), clamped to >= 0 in case + // of counter resets between reconnects within the same + // SessionManager lifetime. + rxV4 := max(0.0, float64(snap.RxBytesV4-sm.prevSnap.RxBytesV4)/elapsed) + txV4 := max(0.0, float64(snap.TxBytesV4-sm.prevSnap.TxBytesV4)/elapsed) + rxV6 := max(0.0, float64(snap.RxBytesV6-sm.prevSnap.RxBytesV6)/elapsed) + txV6 := max(0.0, float64(snap.TxBytesV6-sm.prevSnap.TxBytesV6)/elapsed) + if sm.ewmaRxV4 == 0 && sm.ewmaTxV4 == 0 && sm.ewmaRxV6 == 0 && sm.ewmaTxV6 == 0 { + // First real sample: seed instead of ramping from 0. + sm.ewmaRxV4, sm.ewmaTxV4, sm.ewmaRxV6, sm.ewmaTxV6 = rxV4, txV4, rxV6, txV6 + } else { + sm.ewmaRxV4 = sm.ewmaRxV4*(1-ewmaAlpha) + rxV4*ewmaAlpha + sm.ewmaTxV4 = sm.ewmaTxV4*(1-ewmaAlpha) + txV4*ewmaAlpha + sm.ewmaRxV6 = sm.ewmaRxV6*(1-ewmaAlpha) + rxV6*ewmaAlpha + sm.ewmaTxV6 = sm.ewmaTxV6*(1-ewmaAlpha) + txV6*ewmaAlpha + } + snap.RxSpeedV4 = int64(sm.ewmaRxV4) + snap.TxSpeedV4 = int64(sm.ewmaTxV4) + snap.RxSpeedV6 = int64(sm.ewmaRxV6) + snap.TxSpeedV6 = int64(sm.ewmaTxV6) + snap.RxSpeed = snap.RxSpeedV4 + snap.RxSpeedV6 + snap.TxSpeed = snap.TxSpeedV4 + snap.TxSpeedV6 + } + sm.prevSnap = snap + // Clear speed fields on the stored prev copy so we don't + // accidentally carry stale speed into the next delta base + // (only cumulative bytes matter for deltas). + sm.prevSnap.RxSpeedV4, sm.prevSnap.TxSpeedV4 = 0, 0 + sm.prevSnap.RxSpeedV6, sm.prevSnap.TxSpeedV6 = 0, 0 + sm.prevSnap.RxSpeed, sm.prevSnap.TxSpeed = 0, 0 + sm.prevTick = now + sm.speedReady = true + sm.onStats(snap) } } }