init
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"lmvpn/internal/config"
|
||||
"lmvpn/internal/db"
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/keychain"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/model"
|
||||
"lmvpn/internal/paths"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/app"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// App is the GUI application controller.
|
||||
type App struct {
|
||||
fyneApp fyne.App
|
||||
db *db.Store
|
||||
kc keychain.Store
|
||||
window fyne.Window
|
||||
|
||||
// UI widgets
|
||||
profileSelect *widget.Select
|
||||
stateLabel *widget.Label
|
||||
ipLabel *widget.Label
|
||||
uptimeLabel *widget.Label
|
||||
rxLabel *widget.Label
|
||||
txLabel *widget.Label
|
||||
connectBtn *widget.Button
|
||||
disconnectBtn *widget.Button
|
||||
|
||||
// State
|
||||
mu sync.Mutex
|
||||
ipcClient *ipc.Client
|
||||
profiles []model.ServerProfile
|
||||
currentProfile *model.ServerProfile
|
||||
}
|
||||
|
||||
// Run initialises and starts the GUI application.
|
||||
func Run() {
|
||||
// Ensure platform directories exist.
|
||||
if err := paths.EnsureDirs(); err != nil {
|
||||
log.L().Error("ensure dirs", "error", err)
|
||||
}
|
||||
|
||||
// Logging.
|
||||
log.Init(log.RoleGUI, paths.LogFile())
|
||||
|
||||
// Database.
|
||||
store, err := db.Open()
|
||||
if err != nil {
|
||||
log.L().Error("open db", "error", err)
|
||||
}
|
||||
|
||||
// Load app config.
|
||||
cfg, _ := config.Load()
|
||||
|
||||
a := &App{
|
||||
fyneApp: app.NewWithID(paths.BundleID),
|
||||
db: store,
|
||||
kc: keychain.New(),
|
||||
}
|
||||
|
||||
a.window = a.fyneApp.NewWindow("LMVPN")
|
||||
a.window.SetContent(a.buildMainWindow())
|
||||
a.window.Resize(fyne.NewSize(420, 480))
|
||||
|
||||
// Load profiles.
|
||||
if store != nil {
|
||||
a.loadProfiles()
|
||||
}
|
||||
|
||||
// System tray.
|
||||
a.setupTray()
|
||||
|
||||
// Auto-connect if configured.
|
||||
if cfg.AutoConnect && a.currentProfile != nil {
|
||||
go func() {
|
||||
// Small delay to let window render.
|
||||
a.onConnect()
|
||||
}()
|
||||
}
|
||||
|
||||
a.window.SetCloseIntercept(func() {
|
||||
if cfg.CloseToTray {
|
||||
a.window.Hide()
|
||||
} else {
|
||||
a.fyneApp.Quit()
|
||||
}
|
||||
})
|
||||
|
||||
a.window.ShowAndRun()
|
||||
}
|
||||
|
||||
// loadProfiles loads all profiles from the database and populates the
|
||||
// selector.
|
||||
func (a *App) loadProfiles() {
|
||||
profiles, err := a.db.ListProfiles()
|
||||
if err != nil {
|
||||
log.L().Error("list profiles", "error", err)
|
||||
return
|
||||
}
|
||||
a.profiles = profiles
|
||||
names := a.profileNames()
|
||||
a.profileSelect.Options = names
|
||||
if len(names) > 0 {
|
||||
a.profileSelect.SetSelectedIndex(0)
|
||||
a.selectProfileByName(names[0])
|
||||
}
|
||||
}
|
||||
|
||||
// profileNames returns the names of all loaded profiles.
|
||||
func (a *App) profileNames() []string {
|
||||
names := make([]string, len(a.profiles))
|
||||
for i, p := range a.profiles {
|
||||
names[i] = p.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// selectProfileByName sets the current profile by name.
|
||||
func (a *App) selectProfileByName(name string) {
|
||||
for i := range a.profiles {
|
||||
if a.profiles[i].Name == name {
|
||||
a.currentProfile = &a.profiles[i]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onAddProfile shows a dialog to create a new profile.
|
||||
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.ShowInformation("No Profile", "Select a profile to edit.", a.window)
|
||||
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.ShowConfirm("Delete Profile",
|
||||
"Delete profile \""+name+"\" and its stored credentials?",
|
||||
func(ok bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
|
||||
showError("Error", err.Error(), a.window)
|
||||
return
|
||||
}
|
||||
_ = a.kc.DeleteAll(name)
|
||||
a.loadProfiles()
|
||||
}, a.window)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Package ui contains the Fyne desktop GUI. It runs as the user,
|
||||
// manages profiles and credentials (SQLite + Keychain), and controls
|
||||
// the privileged daemon over IPC.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/log"
|
||||
"lmvpn/internal/paths"
|
||||
)
|
||||
|
||||
// ensureDaemon checks if the daemon is running and launches it (as
|
||||
// root via osascript) if not. It blocks until the daemon is reachable
|
||||
// or times out.
|
||||
func ensureDaemon() (*ipc.Client, error) {
|
||||
// Fast path: daemon already running.
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve executable: %w", err)
|
||||
}
|
||||
|
||||
// Launch daemon as root via osascript administrator prompt.
|
||||
script := fmt.Sprintf(
|
||||
`do shell script "nohup %s daemon > /dev/null 2>&1 &" with administrator privileges`,
|
||||
exe)
|
||||
cmd := exec.Command("osascript", "-e", script)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("launch daemon: %w (%s)", err, string(out))
|
||||
}
|
||||
log.L().Info("daemon launched via osascript")
|
||||
|
||||
// Wait for the daemon to become reachable.
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
if c, err := ipc.Dial(); err == nil {
|
||||
log.L().Info("daemon connected", "socket", paths.IPCSocketPath())
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("daemon did not become reachable")
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"lmvpn/internal/model"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// showProfileDialog displays an add/edit dialog for a server profile.
|
||||
// If editing is nil, a new profile is created.
|
||||
func (a *App) showProfileDialog(editing *model.ServerProfile) {
|
||||
isNew := editing == nil
|
||||
|
||||
nameEntry := widget.NewEntry()
|
||||
serverEntry := widget.NewEntry()
|
||||
userEntry := widget.NewEntry()
|
||||
passEntry := widget.NewPasswordEntry()
|
||||
authSelect := widget.NewSelect([]string{"both", "jwt", "password"}, nil)
|
||||
routeSelect := widget.NewSelect([]string{"full", "split", "custom"}, nil)
|
||||
cidrEntry := widget.NewMultiLineEntry()
|
||||
cidrEntry.SetPlaceHolder("10.0.0.0/8, 172.16.0.0/12")
|
||||
mtuEntry := widget.NewEntry()
|
||||
mtuEntry.SetPlaceHolder("0 = use server MTU")
|
||||
|
||||
if !isNew {
|
||||
nameEntry.SetText(editing.Name)
|
||||
serverEntry.SetText(editing.ServerURL)
|
||||
userEntry.SetText(editing.Username)
|
||||
authSelect.SetSelected(string(editing.AuthMode))
|
||||
routeSelect.SetSelected(string(editing.RoutingMode))
|
||||
cidrEntry.SetText(editing.CustomCIDRs)
|
||||
mtuEntry.SetText(fmtInt(editing.MTUOverride))
|
||||
passEntry.SetPlaceHolder("(unchanged)")
|
||||
} else {
|
||||
authSelect.SetSelected(string(model.AuthModeBoth))
|
||||
routeSelect.SetSelected(string(model.RoutingFull))
|
||||
mtuEntry.SetText("0")
|
||||
}
|
||||
|
||||
form := container.NewVBox(
|
||||
widget.NewLabel("Name"), nameEntry,
|
||||
widget.NewLabel("Server URL"), serverEntry,
|
||||
widget.NewLabel("Username"), userEntry,
|
||||
widget.NewLabel("Password"), passEntry,
|
||||
widget.NewLabel("Auth Mode"), authSelect,
|
||||
widget.NewLabel("Routing Mode"), routeSelect,
|
||||
widget.NewLabel("Custom CIDRs (comma-separated)"), cidrEntry,
|
||||
widget.NewLabel("MTU Override"), mtuEntry,
|
||||
)
|
||||
|
||||
d := dialog.NewCustomConfirm("Profile", "Save", "Cancel", form, func(save bool) {
|
||||
if !save {
|
||||
return
|
||||
}
|
||||
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
|
||||
userEntry.Text, passEntry.Text,
|
||||
authSelect.Selected, routeSelect.Selected,
|
||||
cidrEntry.Text, mtuEntry.Text, isNew)
|
||||
}, a.window)
|
||||
d.Resize(fyne.NewSize(400, 500))
|
||||
d.Show()
|
||||
}
|
||||
|
||||
// saveProfile creates or updates a profile and stores credentials.
|
||||
func (a *App) saveProfile(editing *model.ServerProfile,
|
||||
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
|
||||
if name == "" || server == "" || user == "" {
|
||||
showError("Validation", "Name, Server URL, and Username are required.", a.window)
|
||||
return
|
||||
}
|
||||
|
||||
mtu := parseIntDefault(mtuStr, 0)
|
||||
|
||||
if isNew {
|
||||
p := &model.ServerProfile{
|
||||
Name: name,
|
||||
ServerURL: server,
|
||||
Username: user,
|
||||
AuthMode: model.AuthMode(authMode),
|
||||
RoutingMode: model.RoutingMode(routeMode),
|
||||
CustomCIDRs: cidrs,
|
||||
MTUOverride: mtu,
|
||||
}
|
||||
id, err := a.db.CreateProfile(p)
|
||||
if err != nil {
|
||||
showError("Save Error", err.Error(), a.window)
|
||||
return
|
||||
}
|
||||
_ = id
|
||||
if password != "" {
|
||||
if err := a.kc.SetPassword(name, password); err != nil {
|
||||
showError("Keychain Error", err.Error(), a.window)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
oldName := editing.Name
|
||||
editing.Name = name
|
||||
editing.ServerURL = server
|
||||
editing.Username = user
|
||||
editing.AuthMode = model.AuthMode(authMode)
|
||||
editing.RoutingMode = model.RoutingMode(routeMode)
|
||||
editing.CustomCIDRs = cidrs
|
||||
editing.MTUOverride = mtu
|
||||
if err := a.db.UpdateProfile(editing); err != nil {
|
||||
showError("Save Error", err.Error(), a.window)
|
||||
return
|
||||
}
|
||||
if password != "" {
|
||||
_ = a.kc.DeleteAll(oldName)
|
||||
if err := a.kc.SetPassword(name, password); err != nil {
|
||||
showError("Keychain Error", err.Error(), a.window)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a.loadProfiles()
|
||||
}
|
||||
|
||||
func fmtInt(n int) string {
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
func parseIntDefault(s string, def int) int {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/driver/desktop"
|
||||
)
|
||||
|
||||
// setupTray configures the system tray menu (desktop only).
|
||||
func (a *App) setupTray() {
|
||||
deskApp, ok := a.fyneApp.(desktop.App)
|
||||
if !ok {
|
||||
return // not a desktop app, skip tray
|
||||
}
|
||||
menu := fyne.NewMenu("LMVPN",
|
||||
fyne.NewMenuItem("Show Window", func() {
|
||||
a.window.Show()
|
||||
a.window.RequestFocus()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem("Connect", func() {
|
||||
a.onConnect()
|
||||
}),
|
||||
fyne.NewMenuItem("Disconnect", func() {
|
||||
a.onDisconnect()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem("Quit", func() {
|
||||
a.fyneApp.Quit()
|
||||
}),
|
||||
)
|
||||
deskApp.SetSystemTrayMenu(menu)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"lmvpn/internal/ipc"
|
||||
"lmvpn/internal/stats"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
)
|
||||
|
||||
// showError displays a titled error dialog.
|
||||
func showError(title, message string, parent fyne.Window) {
|
||||
dialog.NewCustom(title, "OK", widget.NewLabel(message), parent).Show()
|
||||
}
|
||||
|
||||
// buildMainWindow creates the main application window layout.
|
||||
func (a *App) buildMainWindow() fyne.CanvasObject {
|
||||
// Profile selector.
|
||||
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
|
||||
a.selectProfileByName(sel)
|
||||
})
|
||||
|
||||
// Status display.
|
||||
a.stateLabel = widget.NewLabel("Disconnected")
|
||||
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
|
||||
a.ipLabel = widget.NewLabel("IP: —")
|
||||
a.uptimeLabel = widget.NewLabel("Uptime: —")
|
||||
a.rxLabel = widget.NewLabel("↓ 0 B")
|
||||
a.txLabel = widget.NewLabel("↑ 0 B")
|
||||
|
||||
statusCard := widget.NewCard("Status", "", container.NewVBox(
|
||||
a.stateLabel,
|
||||
a.ipLabel,
|
||||
a.uptimeLabel,
|
||||
container.NewHBox(a.rxLabel, a.txLabel),
|
||||
))
|
||||
|
||||
// Buttons.
|
||||
a.connectBtn = widget.NewButton("Connect", a.onConnect)
|
||||
a.connectBtn.Importance = widget.HighImportance
|
||||
a.disconnectBtn = widget.NewButton("Disconnect", a.onDisconnect)
|
||||
a.disconnectBtn.Disable()
|
||||
|
||||
addBtn := widget.NewButton("Add Profile", a.onAddProfile)
|
||||
editBtn := widget.NewButton("Edit", a.onEditProfile)
|
||||
deleteBtn := widget.NewButton("Delete", a.onDeleteProfile)
|
||||
|
||||
buttons := container.NewGridWithColumns(2,
|
||||
a.connectBtn, a.disconnectBtn,
|
||||
)
|
||||
profileButtons := container.NewGridWithColumns(3,
|
||||
addBtn, editBtn, deleteBtn,
|
||||
)
|
||||
|
||||
return container.NewVBox(
|
||||
widget.NewLabel("Profile"),
|
||||
a.profileSelect,
|
||||
buttons,
|
||||
profileButtons,
|
||||
statusCard,
|
||||
)
|
||||
}
|
||||
|
||||
// onConnect handles the Connect button click.
|
||||
func (a *App) onConnect() {
|
||||
if a.currentProfile == nil {
|
||||
showError("No Profile", "Please select or create a profile first.", a.window)
|
||||
return
|
||||
}
|
||||
|
||||
a.connectBtn.Disable()
|
||||
a.stateLabel.SetText("Connecting...")
|
||||
|
||||
go func() {
|
||||
// Ensure daemon is running.
|
||||
client, err := ensureDaemon()
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
showError("Daemon Error", err.Error(), a.window)
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
a.connectBtn.Enable()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.ipcClient = client
|
||||
a.mu.Unlock()
|
||||
|
||||
// Get password from keychain.
|
||||
password, err := a.kc.GetPassword(a.currentProfile.Name)
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
showError("Credential Error",
|
||||
"No password stored for this profile. Edit the profile to set it.",
|
||||
a.window)
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
a.connectBtn.Enable()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Build and send the start command.
|
||||
cfg := ipc.ClientConfig{
|
||||
ServerURL: a.currentProfile.ServerURL,
|
||||
Username: a.currentProfile.Username,
|
||||
Password: password,
|
||||
AuthMode: string(a.currentProfile.AuthMode),
|
||||
RoutingMode: string(a.currentProfile.RoutingMode),
|
||||
CustomCIDRs: splitCIDRs(a.currentProfile.CustomCIDRs),
|
||||
MTUOverride: a.currentProfile.MTUOverride,
|
||||
}
|
||||
if err := ipc.SendStart(client, cfg); err != nil {
|
||||
fyne.Do(func() {
|
||||
showError("IPC Error", err.Error(), a.window)
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
a.connectBtn.Enable()
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Start the event listener.
|
||||
go a.eventLoop()
|
||||
}()
|
||||
}
|
||||
|
||||
// onDisconnect handles the Disconnect button click.
|
||||
func (a *App) onDisconnect() {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
_ = ipc.SendStop(client)
|
||||
a.disconnectBtn.Disable()
|
||||
a.connectBtn.Enable()
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
}
|
||||
|
||||
// eventLoop reads IPC events from the daemon and updates the UI.
|
||||
func (a *App) eventLoop() {
|
||||
for {
|
||||
a.mu.Lock()
|
||||
client := a.ipcClient
|
||||
a.mu.Unlock()
|
||||
if client == nil {
|
||||
return
|
||||
}
|
||||
ev, err := client.Recv()
|
||||
if err != nil {
|
||||
fyne.Do(func() {
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
a.ipLabel.SetText("IP: —")
|
||||
a.uptimeLabel.SetText("Uptime: —")
|
||||
a.rxLabel.SetText("↓ 0 B")
|
||||
a.txLabel.SetText("↑ 0 B")
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
})
|
||||
return
|
||||
}
|
||||
switch ev.Event {
|
||||
case ipc.EvState:
|
||||
fyne.Do(func() {
|
||||
a.applyState(ev.State)
|
||||
})
|
||||
case ipc.EvStats:
|
||||
if ev.Stats != nil {
|
||||
s := *ev.Stats
|
||||
fyne.Do(func() {
|
||||
a.applyStats(s)
|
||||
})
|
||||
}
|
||||
case ipc.EvError:
|
||||
fyne.Do(func() {
|
||||
if ev.Message != "" {
|
||||
showError("VPN Error", ev.Message, a.window)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyState updates UI elements for a state change.
|
||||
func (a *App) applyState(state string) {
|
||||
switch stats.State(state) {
|
||||
case stats.StateConnected:
|
||||
a.stateLabel.SetText("Connected")
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
case stats.StateConnecting:
|
||||
a.stateLabel.SetText("Connecting...")
|
||||
a.connectBtn.Disable()
|
||||
a.disconnectBtn.Enable()
|
||||
case stats.StateReconnecting:
|
||||
a.stateLabel.SetText("Reconnecting...")
|
||||
a.disconnectBtn.Enable()
|
||||
case stats.StateDisconnected:
|
||||
a.stateLabel.SetText("Disconnected")
|
||||
a.ipLabel.SetText("IP: —")
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
case stats.StateError:
|
||||
a.stateLabel.SetText("Error")
|
||||
a.connectBtn.Enable()
|
||||
a.disconnectBtn.Disable()
|
||||
}
|
||||
}
|
||||
|
||||
// applyStats updates the stats display.
|
||||
func (a *App) applyStats(s stats.Snapshot) {
|
||||
if s.AssignedIP != "" {
|
||||
a.ipLabel.SetText("IP: " + s.AssignedIP)
|
||||
}
|
||||
if s.State == stats.StateConnected {
|
||||
a.stateLabel.SetText("Connected")
|
||||
}
|
||||
a.rxLabel.SetText("↓ " + formatBytes(s.RxBytes))
|
||||
a.txLabel.SetText("↑ " + formatBytes(s.TxBytes))
|
||||
if s.Uptime > 0 {
|
||||
a.uptimeLabel.SetText("Uptime: " + formatDuration(s.Uptime))
|
||||
}
|
||||
}
|
||||
|
||||
// formatBytes formats a byte count human-readably.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatDuration formats an uptime duration.
|
||||
func formatDuration(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
h := int(d.Hours())
|
||||
m := int(d.Minutes()) % 60
|
||||
s := int(d.Seconds()) % 60
|
||||
if h > 0 {
|
||||
return fmt.Sprintf("%dh %dm %ds", h, m, s)
|
||||
}
|
||||
if m > 0 {
|
||||
return fmt.Sprintf("%dm %ds", m, s)
|
||||
}
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
|
||||
// splitCIDRs splits a comma-separated CIDR string into a slice.
|
||||
func splitCIDRs(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
start := 0
|
||||
for i := 0; i <= len(s); i++ {
|
||||
if i == len(s) || s[i] == ',' {
|
||||
if i > start {
|
||||
out = append(out, s[start:i])
|
||||
}
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user