In the previous chapter, Log Processing Pipeline, we learned how to extract, clean, and stream logs from deep inside the container engine.
But right now, that data is just sitting inside our Go application. We need a way to deliver it to the user's browser.
In this chapter, we will build the Web Request Router and implement Server-Sent Events (SSE). This layer acts as the bridge between the user interface and the internal logic of Dozzle.
Imagine you are running a busy restaurant kitchen (the backend). You have chefs cooking (Log Processing) and a manager tracking inventory (Container Store).
However, you have customers (the Frontend) sitting in the dining room.
The Router is the entry point for all traffic. When a browser requests a URL like http://localhost:8080/api/containers, the Router decides which Go function should handle that request.
In Dozzle, we use a library called chi. It allows us to organize URLs into neat groups.
In internal/web/routes.go, we define the map of the world.
// internal/web/routes.go (Simplified)
func createRouter(h *handler) *chi.Mux {
r := chi.NewRouter()
// Group all API calls under "/api"
r.Route("/api", func(r chi.Router) {
// If a user asks for events, start the stream
r.Get("/events/stream", h.streamEvents)
// If a user wants to download logs
r.Get("/containers/{id}/download", h.downloadLogs)
})
return r
}
Beginner Note:
r.Getmeans "Listen for HTTP GET requests". The second argument is the function that actually does the work.
Standard HTTP requests are short-lived:
This works for loading the site, but it fails for Logs and Status Updates. Logs are infinite!
We use Server-Sent Events (SSE).
This is like a stock ticker tape running at the bottom of a TV screen.
Let's look at how we implement the "Ticker Tape" in internal/web/events.go. This is one of the most important functions in the entire app: streamEvents.
When the frontend calls /api/events/stream, we wrap the standard HTTP writer with an SSE Writer.
// internal/web/events.go
func (h *handler) streamEvents(w http.ResponseWriter, r *http.Request) {
// Upgrade the connection to support SSE
sseWriter, err := support_web.NewSSEWriter(r.Context(), w, r)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
// Ensure we close the connection when the function exits
defer sseWriter.Close()
Remember the In-Memory Container Store? We need to ask it to notify us whenever something changes.
// Create channels (pipes) to receive data
events := make(chan container.ContainerEvent)
stats := make(chan container.ContainerStat)
// Tell the HostService: "Send any new events to these channels"
h.hostService.SubscribeEventsAndStats(r.Context(), events, stats)
Now, the function enters an infinite loop. It sits and waits. When data arrives on a channel, it instantly pushes it to the browser.
for {
select {
// Case A: A container changed state (e.g., died or started)
case event := <-events:
// Send JSON message to browser: type="container-event"
sseWriter.Event("container-event", event)
// Case B: New CPU/Memory stats arrived
case stat := <-stats:
sseWriter.Event("container-stat", stat)
// Case C: The user closed the browser tab
case <-r.Context().Done():
return // Stop the loop
}
}
}
Let's visualize what happens when you open the Dozzle dashboard.
While SSE handles the streaming data, the Router also handles specific commands, like telling a container to stop or restart.
In internal/web/routes.go, we defined:
r.Post("/hosts/{host}/containers/{id}/actions/{action}", h.containerActions)
When the user clicks "Restart", the Frontend sends a POST request. The router sends it to h.containerActions.
// Simplified view of an action handler
func (h *handler) containerActions(w http.ResponseWriter, r *http.Request) {
action := chi.URLParam(r, "action") // e.g., "restart"
id := chi.URLParam(r, "id") // e.g., "a1b2c3d4"
// Call the internal service to perform the action
err := h.hostService.ContainerAction(action, id)
if err != nil {
http.Error(w, err.Error(), 500)
}
}
You might wonder why we don't do everything over one connection.
This separation keeps the application architecture clean. If the stream disconnects, the buttons to restart a container still work via standard HTTP.
We have successfully exposed our internal application to the outside world!
Now that the backend is pushing all this exciting data, we need a way for the Frontend to catch it, store it, and display it without freezing the user's browser.
Next Chapter: Frontend State Management (Pinia)
Generated by Code IQ