512 lines
14 KiB
Go
512 lines
14 KiB
Go
|
|
package system
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bufio"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"os/exec"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ── Types ───────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
type CoreUsage struct {
|
||
|
|
Core int `json:"core"`
|
||
|
|
User float64 `json:"user"`
|
||
|
|
System float64 `json:"system"`
|
||
|
|
Idle float64 `json:"idle"`
|
||
|
|
IOWait float64 `json:"iowait"`
|
||
|
|
Percent float64 `json:"percent"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type CPUInfo struct {
|
||
|
|
Overall float64 `json:"overall"`
|
||
|
|
Idle float64 `json:"idle"`
|
||
|
|
Cores []CoreUsage `json:"cores"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type MemInfo struct {
|
||
|
|
TotalBytes uint64 `json:"total_bytes"`
|
||
|
|
UsedBytes uint64 `json:"used_bytes"`
|
||
|
|
FreeBytes uint64 `json:"free_bytes"`
|
||
|
|
AvailableBytes uint64 `json:"available_bytes"`
|
||
|
|
BuffersBytes uint64 `json:"buffers_bytes"`
|
||
|
|
CachedBytes uint64 `json:"cached_bytes"`
|
||
|
|
SwapTotalBytes uint64 `json:"swap_total_bytes"`
|
||
|
|
SwapUsedBytes uint64 `json:"swap_used_bytes"`
|
||
|
|
SwapFreeBytes uint64 `json:"swap_free_bytes"`
|
||
|
|
Total string `json:"total"`
|
||
|
|
Used string `json:"used"`
|
||
|
|
Free string `json:"free"`
|
||
|
|
Available string `json:"available"`
|
||
|
|
Buffers string `json:"buffers"`
|
||
|
|
Cached string `json:"cached"`
|
||
|
|
SwapTotal string `json:"swap_total"`
|
||
|
|
SwapUsed string `json:"swap_used"`
|
||
|
|
SwapFree string `json:"swap_free"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type DiskInfo struct {
|
||
|
|
Filesystem string `json:"filesystem"`
|
||
|
|
Size string `json:"size"`
|
||
|
|
Used string `json:"used"`
|
||
|
|
Available string `json:"available"`
|
||
|
|
UsePercent string `json:"use_percent"`
|
||
|
|
MountPoint string `json:"mount_point"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type NetInfo struct {
|
||
|
|
Interface string `json:"interface"`
|
||
|
|
RxBytes uint64 `json:"rx_bytes"`
|
||
|
|
RxPackets uint64 `json:"rx_packets"`
|
||
|
|
RxErrors uint64 `json:"rx_errors"`
|
||
|
|
RxDropped uint64 `json:"rx_dropped"`
|
||
|
|
TxBytes uint64 `json:"tx_bytes"`
|
||
|
|
TxPackets uint64 `json:"tx_packets"`
|
||
|
|
TxErrors uint64 `json:"tx_errors"`
|
||
|
|
TxDropped uint64 `json:"tx_dropped"`
|
||
|
|
RxHuman string `json:"rx_human"`
|
||
|
|
TxHuman string `json:"tx_human"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type UptimeInfo struct {
|
||
|
|
Seconds float64 `json:"seconds"`
|
||
|
|
IdleSeconds float64 `json:"idle_seconds"`
|
||
|
|
HumanReadable string `json:"human_readable"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type LoadInfo struct {
|
||
|
|
Load1 float64 `json:"load_1"`
|
||
|
|
Load5 float64 `json:"load_5"`
|
||
|
|
Load15 float64 `json:"load_15"`
|
||
|
|
RunningProcs int `json:"running_procs"`
|
||
|
|
TotalProcs int `json:"total_procs"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ProcessInfo struct {
|
||
|
|
PID int `json:"pid"`
|
||
|
|
User string `json:"user"`
|
||
|
|
CPU float64 `json:"cpu"`
|
||
|
|
Mem float64 `json:"mem"`
|
||
|
|
RSS int64 `json:"rss"`
|
||
|
|
Command string `json:"command"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── CPU ─────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
// readCPUStats reads /proc/stat and returns a map of cpu label to field slices.
|
||
|
|
func readCPUStats() (map[string][]uint64, error) {
|
||
|
|
f, err := os.Open("/proc/stat")
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("opening /proc/stat: %w", err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
result := make(map[string][]uint64)
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
if !strings.HasPrefix(line, "cpu") {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 5 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
label := fields[0]
|
||
|
|
var vals []uint64
|
||
|
|
for _, field := range fields[1:] {
|
||
|
|
v, _ := strconv.ParseUint(field, 10, 64)
|
||
|
|
vals = append(vals, v)
|
||
|
|
}
|
||
|
|
result[label] = vals
|
||
|
|
}
|
||
|
|
return result, scanner.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// parseCPUFields converts raw jiffie counts into a CoreUsage.
|
||
|
|
// Fields: user, nice, system, idle, iowait, irq, softirq, steal, guest, guest_nice
|
||
|
|
func parseCPUFields(core int, before, after []uint64) CoreUsage {
|
||
|
|
cu := CoreUsage{Core: core}
|
||
|
|
if len(before) < 5 || len(after) < 5 {
|
||
|
|
return cu
|
||
|
|
}
|
||
|
|
|
||
|
|
// Sum all fields for total jiffies
|
||
|
|
var totalBefore, totalAfter uint64
|
||
|
|
for _, v := range before {
|
||
|
|
totalBefore += v
|
||
|
|
}
|
||
|
|
for _, v := range after {
|
||
|
|
totalAfter += v
|
||
|
|
}
|
||
|
|
|
||
|
|
totalDelta := float64(totalAfter - totalBefore)
|
||
|
|
if totalDelta == 0 {
|
||
|
|
return cu
|
||
|
|
}
|
||
|
|
|
||
|
|
userDelta := float64((after[0] + after[1]) - (before[0] + before[1]))
|
||
|
|
systemDelta := float64(after[2] - before[2])
|
||
|
|
idleDelta := float64(after[3] - before[3])
|
||
|
|
var iowaitDelta float64
|
||
|
|
if len(after) > 4 && len(before) > 4 {
|
||
|
|
iowaitDelta = float64(after[4] - before[4])
|
||
|
|
}
|
||
|
|
|
||
|
|
cu.User = userDelta / totalDelta * 100
|
||
|
|
cu.System = systemDelta / totalDelta * 100
|
||
|
|
cu.Idle = idleDelta / totalDelta * 100
|
||
|
|
cu.IOWait = iowaitDelta / totalDelta * 100
|
||
|
|
cu.Percent = 100 - cu.Idle
|
||
|
|
|
||
|
|
return cu
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetCPUUsage samples /proc/stat twice with a brief interval to compute usage.
|
||
|
|
func GetCPUUsage() (CPUInfo, error) {
|
||
|
|
info := CPUInfo{}
|
||
|
|
|
||
|
|
before, err := readCPUStats()
|
||
|
|
if err != nil {
|
||
|
|
return info, err
|
||
|
|
}
|
||
|
|
|
||
|
|
time.Sleep(250 * time.Millisecond)
|
||
|
|
|
||
|
|
after, err := readCPUStats()
|
||
|
|
if err != nil {
|
||
|
|
return info, err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Overall CPU (the "cpu" aggregate line)
|
||
|
|
if bv, ok := before["cpu"]; ok {
|
||
|
|
if av, ok := after["cpu"]; ok {
|
||
|
|
overall := parseCPUFields(-1, bv, av)
|
||
|
|
info.Overall = overall.Percent
|
||
|
|
info.Idle = overall.Idle
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Per-core
|
||
|
|
for i := 0; ; i++ {
|
||
|
|
label := fmt.Sprintf("cpu%d", i)
|
||
|
|
bv, ok1 := before[label]
|
||
|
|
av, ok2 := after[label]
|
||
|
|
if !ok1 || !ok2 {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
info.Cores = append(info.Cores, parseCPUFields(i, bv, av))
|
||
|
|
}
|
||
|
|
|
||
|
|
return info, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Memory ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetMemory() (MemInfo, error) {
|
||
|
|
info := MemInfo{}
|
||
|
|
|
||
|
|
f, err := os.Open("/proc/meminfo")
|
||
|
|
if err != nil {
|
||
|
|
return info, fmt.Errorf("opening /proc/meminfo: %w", err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
vals := make(map[string]uint64)
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
for scanner.Scan() {
|
||
|
|
line := scanner.Text()
|
||
|
|
parts := strings.SplitN(line, ":", 2)
|
||
|
|
if len(parts) != 2 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
key := strings.TrimSpace(parts[0])
|
||
|
|
valStr := strings.TrimSpace(parts[1])
|
||
|
|
valStr = strings.TrimSuffix(valStr, " kB")
|
||
|
|
valStr = strings.TrimSpace(valStr)
|
||
|
|
v, err := strconv.ParseUint(valStr, 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
vals[key] = v * 1024 // convert kB to bytes
|
||
|
|
}
|
||
|
|
if err := scanner.Err(); err != nil {
|
||
|
|
return info, err
|
||
|
|
}
|
||
|
|
|
||
|
|
info.TotalBytes = vals["MemTotal"]
|
||
|
|
info.FreeBytes = vals["MemFree"]
|
||
|
|
info.AvailableBytes = vals["MemAvailable"]
|
||
|
|
info.BuffersBytes = vals["Buffers"]
|
||
|
|
info.CachedBytes = vals["Cached"]
|
||
|
|
info.SwapTotalBytes = vals["SwapTotal"]
|
||
|
|
info.SwapFreeBytes = vals["SwapFree"]
|
||
|
|
info.SwapUsedBytes = info.SwapTotalBytes - info.SwapFreeBytes
|
||
|
|
info.UsedBytes = info.TotalBytes - info.FreeBytes - info.BuffersBytes - info.CachedBytes
|
||
|
|
if info.UsedBytes > info.TotalBytes {
|
||
|
|
// Overflow guard: if buffers+cached > total-free, use simpler calculation
|
||
|
|
info.UsedBytes = info.TotalBytes - info.AvailableBytes
|
||
|
|
}
|
||
|
|
|
||
|
|
info.Total = humanBytes(info.TotalBytes)
|
||
|
|
info.Used = humanBytes(info.UsedBytes)
|
||
|
|
info.Free = humanBytes(info.FreeBytes)
|
||
|
|
info.Available = humanBytes(info.AvailableBytes)
|
||
|
|
info.Buffers = humanBytes(info.BuffersBytes)
|
||
|
|
info.Cached = humanBytes(info.CachedBytes)
|
||
|
|
info.SwapTotal = humanBytes(info.SwapTotalBytes)
|
||
|
|
info.SwapUsed = humanBytes(info.SwapUsedBytes)
|
||
|
|
info.SwapFree = humanBytes(info.SwapFreeBytes)
|
||
|
|
|
||
|
|
return info, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Disk ────────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetDisk() ([]DiskInfo, error) {
|
||
|
|
// Try with filesystem type filters first for real block devices
|
||
|
|
out, err := exec.Command("df", "-h", "--type=ext4", "--type=xfs", "--type=btrfs", "--type=ext3").CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
// Fallback: exclude pseudo filesystems
|
||
|
|
out, err = exec.Command("df", "-h", "--exclude-type=tmpfs", "--exclude-type=devtmpfs", "--exclude-type=squashfs").CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
// Last resort: all filesystems
|
||
|
|
out, err = exec.Command("df", "-h").CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("df command failed: %w (%s)", err, string(out))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var disks []DiskInfo
|
||
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||
|
|
for i, line := range lines {
|
||
|
|
if i == 0 || strings.TrimSpace(line) == "" {
|
||
|
|
continue // skip header
|
||
|
|
}
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 6 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
disks = append(disks, DiskInfo{
|
||
|
|
Filesystem: fields[0],
|
||
|
|
Size: fields[1],
|
||
|
|
Used: fields[2],
|
||
|
|
Available: fields[3],
|
||
|
|
UsePercent: fields[4],
|
||
|
|
MountPoint: fields[5],
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return disks, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Network ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetNetwork() ([]NetInfo, error) {
|
||
|
|
f, err := os.Open("/proc/net/dev")
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("opening /proc/net/dev: %w", err)
|
||
|
|
}
|
||
|
|
defer f.Close()
|
||
|
|
|
||
|
|
var interfaces []NetInfo
|
||
|
|
scanner := bufio.NewScanner(f)
|
||
|
|
lineNum := 0
|
||
|
|
for scanner.Scan() {
|
||
|
|
lineNum++
|
||
|
|
if lineNum <= 2 {
|
||
|
|
continue // skip the two header lines
|
||
|
|
}
|
||
|
|
|
||
|
|
line := scanner.Text()
|
||
|
|
// Format: " iface: rx_bytes rx_packets rx_errs rx_drop ... tx_bytes tx_packets tx_errs tx_drop ..."
|
||
|
|
colonIdx := strings.Index(line, ":")
|
||
|
|
if colonIdx < 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
iface := strings.TrimSpace(line[:colonIdx])
|
||
|
|
rest := strings.TrimSpace(line[colonIdx+1:])
|
||
|
|
fields := strings.Fields(rest)
|
||
|
|
if len(fields) < 10 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
rxBytes, _ := strconv.ParseUint(fields[0], 10, 64)
|
||
|
|
rxPackets, _ := strconv.ParseUint(fields[1], 10, 64)
|
||
|
|
rxErrors, _ := strconv.ParseUint(fields[2], 10, 64)
|
||
|
|
rxDropped, _ := strconv.ParseUint(fields[3], 10, 64)
|
||
|
|
txBytes, _ := strconv.ParseUint(fields[8], 10, 64)
|
||
|
|
txPackets, _ := strconv.ParseUint(fields[9], 10, 64)
|
||
|
|
txErrors, _ := strconv.ParseUint(fields[10], 10, 64)
|
||
|
|
txDropped, _ := strconv.ParseUint(fields[11], 10, 64)
|
||
|
|
|
||
|
|
interfaces = append(interfaces, NetInfo{
|
||
|
|
Interface: iface,
|
||
|
|
RxBytes: rxBytes,
|
||
|
|
RxPackets: rxPackets,
|
||
|
|
RxErrors: rxErrors,
|
||
|
|
RxDropped: rxDropped,
|
||
|
|
TxBytes: txBytes,
|
||
|
|
TxPackets: txPackets,
|
||
|
|
TxErrors: txErrors,
|
||
|
|
TxDropped: txDropped,
|
||
|
|
RxHuman: humanBytes(rxBytes),
|
||
|
|
TxHuman: humanBytes(txBytes),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return interfaces, scanner.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Uptime ──────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetUptime() (UptimeInfo, error) {
|
||
|
|
info := UptimeInfo{}
|
||
|
|
|
||
|
|
data, err := os.ReadFile("/proc/uptime")
|
||
|
|
if err != nil {
|
||
|
|
return info, fmt.Errorf("reading /proc/uptime: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||
|
|
if len(fields) < 2 {
|
||
|
|
return info, fmt.Errorf("unexpected /proc/uptime format")
|
||
|
|
}
|
||
|
|
|
||
|
|
info.Seconds, _ = strconv.ParseFloat(fields[0], 64)
|
||
|
|
info.IdleSeconds, _ = strconv.ParseFloat(fields[1], 64)
|
||
|
|
|
||
|
|
// Build human readable string
|
||
|
|
totalSec := int(info.Seconds)
|
||
|
|
days := totalSec / 86400
|
||
|
|
hours := (totalSec % 86400) / 3600
|
||
|
|
minutes := (totalSec % 3600) / 60
|
||
|
|
seconds := totalSec % 60
|
||
|
|
|
||
|
|
parts := []string{}
|
||
|
|
if days > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%d day%s", days, plural(days)))
|
||
|
|
}
|
||
|
|
if hours > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%d hour%s", hours, plural(hours)))
|
||
|
|
}
|
||
|
|
if minutes > 0 {
|
||
|
|
parts = append(parts, fmt.Sprintf("%d minute%s", minutes, plural(minutes)))
|
||
|
|
}
|
||
|
|
if len(parts) == 0 || (days == 0 && hours == 0 && minutes == 0) {
|
||
|
|
parts = append(parts, fmt.Sprintf("%d second%s", seconds, plural(seconds)))
|
||
|
|
}
|
||
|
|
|
||
|
|
info.HumanReadable = strings.Join(parts, ", ")
|
||
|
|
|
||
|
|
return info, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Load Average ────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetLoadAvg() (LoadInfo, error) {
|
||
|
|
info := LoadInfo{}
|
||
|
|
|
||
|
|
data, err := os.ReadFile("/proc/loadavg")
|
||
|
|
if err != nil {
|
||
|
|
return info, fmt.Errorf("reading /proc/loadavg: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
fields := strings.Fields(strings.TrimSpace(string(data)))
|
||
|
|
if len(fields) < 4 {
|
||
|
|
return info, fmt.Errorf("unexpected /proc/loadavg format")
|
||
|
|
}
|
||
|
|
|
||
|
|
info.Load1, _ = strconv.ParseFloat(fields[0], 64)
|
||
|
|
info.Load5, _ = strconv.ParseFloat(fields[1], 64)
|
||
|
|
info.Load15, _ = strconv.ParseFloat(fields[2], 64)
|
||
|
|
|
||
|
|
// fields[3] is "running/total" format
|
||
|
|
procParts := strings.SplitN(fields[3], "/", 2)
|
||
|
|
if len(procParts) == 2 {
|
||
|
|
info.RunningProcs, _ = strconv.Atoi(procParts[0])
|
||
|
|
info.TotalProcs, _ = strconv.Atoi(procParts[1])
|
||
|
|
}
|
||
|
|
|
||
|
|
return info, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Top Processes ───────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func GetTopProcesses(n int) ([]ProcessInfo, error) {
|
||
|
|
if n <= 0 {
|
||
|
|
n = 10
|
||
|
|
}
|
||
|
|
|
||
|
|
// ps aux --sort=-%mem gives us processes sorted by memory usage descending
|
||
|
|
out, err := exec.Command("ps", "aux", "--sort=-%mem").CombinedOutput()
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("ps command failed: %w (%s)", err, string(out))
|
||
|
|
}
|
||
|
|
|
||
|
|
var procs []ProcessInfo
|
||
|
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||
|
|
for i, line := range lines {
|
||
|
|
if i == 0 {
|
||
|
|
continue // skip header
|
||
|
|
}
|
||
|
|
if len(procs) >= n {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
|
||
|
|
fields := strings.Fields(line)
|
||
|
|
if len(fields) < 11 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
pid, _ := strconv.Atoi(fields[1])
|
||
|
|
cpu, _ := strconv.ParseFloat(fields[2], 64)
|
||
|
|
mem, _ := strconv.ParseFloat(fields[3], 64)
|
||
|
|
rss, _ := strconv.ParseInt(fields[5], 10, 64)
|
||
|
|
// Command is everything from field 10 onward (may contain spaces)
|
||
|
|
command := strings.Join(fields[10:], " ")
|
||
|
|
|
||
|
|
procs = append(procs, ProcessInfo{
|
||
|
|
PID: pid,
|
||
|
|
User: fields[0],
|
||
|
|
CPU: cpu,
|
||
|
|
Mem: mem,
|
||
|
|
RSS: rss,
|
||
|
|
Command: command,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
return procs, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||
|
|
|
||
|
|
func humanBytes(b uint64) string {
|
||
|
|
const unit = 1024
|
||
|
|
if b < unit {
|
||
|
|
return fmt.Sprintf("%d B", b)
|
||
|
|
}
|
||
|
|
div, exp := uint64(unit), 0
|
||
|
|
for n := b / unit; n >= unit; n /= unit {
|
||
|
|
div *= unit
|
||
|
|
exp++
|
||
|
|
}
|
||
|
|
suffixes := []string{"KiB", "MiB", "GiB", "TiB", "PiB"}
|
||
|
|
if exp >= len(suffixes) {
|
||
|
|
exp = len(suffixes) - 1
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("%.1f %s", float64(b)/float64(div), suffixes[exp])
|
||
|
|
}
|
||
|
|
|
||
|
|
func plural(n int) string {
|
||
|
|
if n == 1 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return "s"
|
||
|
|
}
|