Building Simple Real-Time System Monitor using Go, HTMX, and Web Socket

Didik Tri SusantoDidik Tri Susanto
Nov 13, 2024·
3 min read

I was finding a fun project to work with Go, HTMX, and Tailwwindcss and ended up built a simple real-time web based system monitor with the power of web socket. Here’s the result.

screenshot of simple web based system monitor

It shows system information, memories, disk, CPU, and running processes, updated automatically every 5 seconds.

I’ll break down the code little bit in this post.

Stacks

HTTP Server

type HttpServer struct {
    subscriberMessageBuffer int
    Mux                     http.ServeMux
    subscribersMutex        sync.Mutex
    subscribers             map[*subscriber]struct{}
}

type subscriber struct {
    msgs chan []byte
}

It’s quite straightforward. HttpServer contains a http.ServeMux as http handler and subscribers for web socket broadcasting later. subscriber is simply has msgs channel for data update.

Since it only needs to serve a single HTML file, then it needs URL to show the page, and one URL for web socket connection.

func NewHttpServer() *HttpServer {
    s := &HttpServer{
        subscriberMessageBuffer: 10,
        subscribers:             make(map[*subscriber]struct{}),
    }

    s.Mux.Handle("/", http.FileServer(http.Dir("./views")))
    s.Mux.HandleFunc("/ws", s.subscribeHandler)
    return s
}

Web Socket Connection & Subscriber

Endpoint /ws will handling web socket connection and managing a subscriber. First it will initiate a new subscriber and added it to a map in the http server structure. Lock will be used to prevent race condition since we will use go routine later.

func (s *HttpServer) subscribeHandler(w http.ResponseWriter, r *http.Request) {
    err := s.subscribe(r.Context(), w, r)
    if err != nil {
        fmt.Println(err)
        return
    }
}

func (s *HttpServer) addSubscriber(subscriber *subscriber) {
    s.subscribersMutex.Lock()
    s.subscribers[subscriber] = struct{}{}
    s.subscribersMutex.Unlock()
    fmt.Println("subscriber added", subscriber)
}

Web socket is starting accept a connection and via loop, we will detect an incoming channel msgs from subscriber and write it to web socket.

func (s *HttpServer) subscribe(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
    var c *websocket.Conn
    subscriber := &subscriber{
        msgs: make(chan []byte, s.subscriberMessageBuffer),
    }

    s.addSubscriber(subscriber)

    c, err := websocket.Accept(w, r, nil)
    if err != nil {
        return err
    }

    defer c.CloseNow()

    ctx = c.CloseRead(ctx)
    for {
        select {
        case msg := <-subscriber.msgs:
            ctx, cancel := context.WithTimeout(ctx, time.Second)
            defer cancel()
            err := c.Write(ctx, websocket.MessageText, msg)
            if err != nil {
                return err
            }
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

Auto Update

Auto update the system info data is handled by go routine. We will build a html response that will be sent via web socket and htmx will handle the update on the html side.

func main() {
    fmt.Println("Starting system monitor")
    s := server.NewHttpServer()

    go func(s *server.HttpServer) {
        for {
            hostStat, _ := host.Info()
            timestamp := time.Now().Format("2006-01-02 15:04:05")
            html := `
            <span hx-swap-oob="innerHTML:#data-timestamp">` + timestamp + `</span>
            <span hx-swap-oob="innerHTML:#system-hostname">` + hostStat.Hostname + `</span>
            <span hx-swap-oob="innerHTML:#system-os">` + hostStat.OS + `</span>
            `
            s.Broadcast([]byte(html))
            time.Sleep(time.Second * 5)
        }
    }(s)
    // ...
}

Syntax hx-swap-oob="innerHTML:#data-timestamp" in htmx is tell us that swap a component inside data-timestamp id in our HTML. All swapping mechanism will be the same for other system information components.

All swappable html components will be sent via Broadcast(msg) method and later will be sent via channel every 5 seconds.

func (s *HttpServer) Broadcast(msg []byte) {
    s.subscribersMutex.Lock()
    for subscriber := range s.subscribers {
        subscriber.msgs <- msg
    }
    s.subscribersMutex.Unlock()
}

The HTMX View

It’s plain HTML file and for Tailwindcss I simple used CDN for that

<script src="https://cdn.tailwindcss.com"></script>

Same idea for HTMX and web socket extension for using CDN

<script src="https://unpkg.com/htmx.org@2.0.3" integrity="sha384-0895/pl2MU10Hqc6jd4RvrthNlDiE9U1tWmX7WRESftEDRosgxNsQG/Ze9YMRzHq" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>

How to connect to the web socket?

The system monitor page is expected to receives all the data by web socket so I can set it from the main div container. Specify hx-ext=”ws” to tell HTMX for using web socket extension and ws-connect=”/ws” to tell web socket to connect via /ws URL.

<body class="bg-gray-700 text-white">
    <div class="container mx-auto p-8" hx-ext="ws" ws-connect="/ws">
   <!-- all the data -->
    </div>
</body>

Full Code

Here is the full version of the code https://github.com/didikz/gosysmon-web and you may want to play around with your own version.

Happy coding!

15
Subscribe to my newsletter

Read articles from Didik Tri Susanto directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Didik Tri Susanto
Didik Tri Susanto

Hi, I am a cat lover and software engineer from Malang, mostly doing PHP and stuff. Software Engineer Live in Malang, Indonesia Visit my resume and portfolios at didiktrisusanto.dev See you, folks!