Rez Moss

Rez Moss

Digital Reflections: Exploring Tech, Innovation & Ideas

Building a Real-time System Monitor in Go

Feb 2025

I wanted to build a tool that shows me what’s happening on my system in real-time - CPU usage, memory consumption, network activity, and resource-hungry processes. Let’s build it together!

Understanding What We Need

To monitor a system, we need several pieces of information:

  • Memory usage (total, used, free)
  • CPU utilization percentage
  • Network I/O statistics
  • Running processes and their resource usage

Go has a great package called gopsutil that can give us all this information. Let’s explore what we need:

import (
    "github.com/shirou/gopsutil/v3/cpu"
    "github.com/shirou/gopsutil/v3/mem"
    "github.com/shirou/gopsutil/v3/net"
    "github.com/shirou/gopsutil/v3/process"
)

Let’s see how each part works:

Memory Information

The mem package gives us memory statistics:

v, _ := mem.VirtualMemory()
fmt.Printf("Total: %v, Used: %v, Free: %v\n", v.Total, v.Used, v.Free)

This gives us:

  • Total memory (v.Total)
  • Used memory (v.Used)
  • Free memory (v.Free)
  • Usage percentage (v.UsedPercent)

CPU Usage

The cpu package shows CPU utilization:

// Get CPU percentage (false = all CPUs combined)
percentages, _ := cpu.Percent(0, false)
cpuUsed := percentages[0] // Overall CPU usage percentage

The Percent() function:

  • First argument (0) means immediate reading
  • Second argument (false) means get total usage, not per CPU
  • Returns percentage of CPU in use

Network Statistics

The net package provides network I/O information:

// Get network stats (false = all interfaces combined)
netStats, _ := net.IOCounters(false)
stats := netStats[0]  // Overall network statistics

// Available fields:
// - BytesSent
// - BytesRecv
// - PacketsSent
// - PacketsRecv

This gives us sent/received bytes and packets across all network interfaces.

Process Information

The process package lets us see what each process is doing:

processes, _ := process.Processes()
for _, p := range processes {
    name, _ := p.Name()
    cpu, _ := p.CPUPercent()
    mem, _ := p.MemoryPercent()
    memInfo, _ := p.MemoryInfo()
}

For each process we can get:

  • Process name and PID
  • CPU usage percentage
  • Memory usage percentage
  • Actual memory size (RSS - Resident Set Size)

Building Our Display

We want to show all this in the terminal, so we’ll use tcell for the UI:

import "github.com/gdamore/tcell/v2"

Let’s create a structure to hold process information that we’ll display:

type ProcessInfo struct {
    PID     int32
    Name    string
    CPU     float64
    Memory  float64
    MemSize uint64
}

Now we can write a function to gather all process information:

func getProcessInfo() []ProcessInfo {
    processes, _ := process.Processes()
    var processInfo []ProcessInfo

    for _, p := range processes {
        name, _ := p.Name()
        cpu, _ := p.CPUPercent()
        mem, _ := p.MemoryPercent()
        memInfo, _ := p.MemoryInfo()

        if memInfo != nil {
            processInfo = append(processInfo, ProcessInfo{
                PID:     p.Pid,
                Name:    name,
                CPU:     cpu,
                Memory:  float64(mem),
                MemSize: memInfo.RSS,
            })
        }
    }
    return processInfo
}

We want to show the top resource-using processes, so let’s add sorting:

func sortByMemory(processes []ProcessInfo) []ProcessInfo {
    sort.Slice(processes, func(i, j int) bool {
        return processes[i].MemSize > processes[j].MemSize
    })
    if len(processes) > 10 {
        processes = processes[:10]
    }
    return processes
}

func sortByCPU(processes []ProcessInfo) []ProcessInfo {
    sort.Slice(processes, func(i, j int) bool {
        return processes[i].CPU > processes[j].CPU
    })
    if len(processes) > 10 {
        processes = processes[:10]
    }
    return processes
}

Putting It All Together

Now we have all the pieces to gather system information. Let’s create the main update function that will collect everything:

func updateScreen(screen tcell.Screen) {
    screen.Clear()
    width, height := screen.Size()

    // Get memory stats
    v, _ := mem.VirtualMemory()
    memUsed := float64(v.Used) / float64(v.Total) * 100
    
    // Draw memory bar with detailed info
    drawBar(screen, 1, 1, width-2, "Memory", memUsed,
        fmt.Sprintf("Total: %.2fGB Used: %.2fGB Free: %.2fGB (%.1f%%)",
            float64(v.Total)/1024/1024/1024,
            float64(v.Used)/1024/1024/1024,
            float64(v.Free)/1024/1024/1024,
            memUsed))

    // Get and display CPU usage
    c, _ := cpu.Percent(0, false)
    cpuUsed := c[0]
    drawBar(screen, 1, 3, width-2, "CPU", cpuUsed,
        fmt.Sprintf("Usage: %.1f%%", cpuUsed))

    // Get and display network stats
    n, _ := net.IOCounters(false)
    netStats := fmt.Sprintf("Network - Received: %.2fMB (%d pkts) Sent: %.2fMB (%d pkts)",
        float64(n[0].BytesRecv)/1024/1024,
        n[0].PacketsRecv,
        float64(n[0].BytesSent)/1024/1024,
        n[0].PacketsSent)
    drawText(screen, 1, 5, netStats, tcell.StyleDefault.Foreground(tcell.ColorGreen))

    // Get and display process information
    processes := getProcessInfo()
    drawProcessTable(screen, 1, 7, width/2-1, height-7, "Top Memory Usage", sortByMemory(processes))
    drawProcessTable(screen, width/2+1, 7, width/2-2, height-7, "Top CPU Usage", sortByCPU(processes))

    screen.Show()
}

Adding Visual Components

Now that we can collect system metrics, we need to display them nicely. We’ll use the tcell library to create a terminal UI with:

  • Progress bars for CPU and memory
  • Formatted text for network stats
  • Tables for process information

Let’s start with the basic text display function:

func drawText(screen tcell.Screen, x, y int, text string, style tcell.Style) {
    for i, r := range text {
        screen.SetContent(x+i, y, r, nil, style)
    }
}

This function is pretty straightforward - it puts text at any position (x,y) on the screen with a given style. We’ll use this as a building block for our more complex displays.

Next, let’s create nice-looking progress bars for CPU and memory:

func drawBar(screen tcell.Screen, x, y, width int, label string, value float64, stats string) {
    barWidth := width - 2  // Leave space for [ and ]
    filled := int(float64(barWidth) * value / 100)

    // Draw the label and stats in yellow
    drawText(screen, x, y, label+": "+stats, 
        tcell.StyleDefault.Foreground(tcell.ColorYellow))

    // Draw the bar border and fill
    screen.SetContent(x, y+1, '[', nil, tcell.StyleDefault)
    for i := 0; i < barWidth; i++ {
        char := ' '
        style := tcell.StyleDefault.Background(tcell.ColorDarkGray)
        if i < filled {
            style = tcell.StyleDefault.Background(tcell.ColorGreen)
        }
        screen.SetContent(x+1+i, y+1, char, nil, style)
    }
    screen.SetContent(x+barWidth+1, y+1, ']', nil, tcell.StyleDefault)
}

This creates a progress bar that looks like:

Memory: Total: 16.00GB Used: 8.50GB Free: 7.50GB (53.1%)
[██████████████████████                ]

Now for displaying process information in a table:

func drawProcessTable(screen tcell.Screen, x, y, width, height int, title string, processes []ProcessInfo) {
    // Draw table title in yellow
    drawText(screen, x, y, title, tcell.StyleDefault.Foreground(tcell.ColorYellow))
    
    // Draw column headers in green
    drawText(screen, x, y+1, fmt.Sprintf("%-6s %-20s %-10s %-10s", 
        "PID", "Name", "CPU%", "Memory"),
        tcell.StyleDefault.Foreground(tcell.ColorGreen))

    // Draw each process row
    for i, p := range processes {
        if i >= height-3 {  // Leave space for title and headers
            break
        }
        drawText(screen, x, y+2+i, fmt.Sprintf("%-6d %-20s %-10.1f %-10.1f",
            p.PID, truncateString(p.Name, 20), p.CPU, p.Memory),
            tcell.StyleDefault)
    }
}

// Helper to prevent long process names from breaking the layout
func truncateString(s string, length int) string {
    if len(s) <= length {
        return s
    }
    return s[:length-3] + "..."
}

This creates tables that look like:

Top Memory Usage
PID    Name                 CPU%       Memory    
1234   chrome              15.5       234.5
5678   vscode              12.3       185.7

Making It Update in Real-time

We want our display to update every second. Let’s set up the main program structure:

func main() {
    // Initialize screen
    screen, err := tcell.NewScreen()
    if err != nil {
        fmt.Fprintf(os.Stderr, "%v\n", err)
        os.Exit(1)
    }

    if err := screen.Init(); err != nil {
        fmt.Fprintf(os.Stderr, "%v\n", err)
        os.Exit(1)
    }

    // Clean up on exit
    defer screen.Fini()

    // Set default colors
    screen.SetStyle(tcell.StyleDefault.
        Background(tcell.ColorBlack).
        Foreground(tcell.ColorWhite))
    screen.Clear()

    // Set up clean shutdown
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    // Handle Ctrl+C gracefully
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)

    // Start update loop
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            select {
            case <-ctx.Done():
                return
            case <-sigChan:
                cancel()
                return
            default:
                updateScreen(screen)
                time.Sleep(1 * time.Second)
            }
        }
    }()

    // Handle keyboard input
    for {
        switch ev := screen.PollEvent().(type) {
        case *tcell.EventKey:
            if ev.Key() == tcell.KeyEscape {
                cancel()
                return
            }
        case *tcell.EventResize:
            screen.Sync()
        }
    }
}

This setup:

  1. Initializes the terminal screen
  2. Creates a goroutine that updates the display every second
  3. Handles window resizing
  4. Allows clean exit with ESC or Ctrl+C

Run the program and you’ll see:

  • Top section: Memory and CPU bars updating in real-time
  • Middle: Current network traffic statistics
  • Bottom half: Two tables showing top processes by memory and CPU usage
  • Everything updates automatically every second
  • ESC or Ctrl+C to exit cleanly

The program gives us a clear view of system resource usage with a responsive, easy-to-read interface. You can leave it running to monitor system behavior or spot resource-hungry processes.

Full code available on GitHub.