整合配置列表
This commit is contained in:
+18
-4
@@ -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"
|
||||
|
||||
@@ -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 = "断开连接"
|
||||
|
||||
+74
-6
@@ -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(),
|
||||
|
||||
+38
-41
@@ -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())
|
||||
|
||||
+97
-1
@@ -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()
|
||||
}
|
||||
|
||||
+52
-17
@@ -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)
|
||||
|
||||
+62
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user