- Add structured logging with slog throughout application - Implement real-time updates using Server-Sent Events and HTMX - Add broadcaster system for instant UI updates when agents report stats - Replace meta refresh with HTMX-powered seamless updates - Add new API endpoints for HTMX fragments and SSE events - Update templates to use HTMX for instant data refresh - Enhance README with real-time features and updated documentation - Remove obsolete template generation file
396 lines
11 KiB
Go
396 lines
11 KiB
Go
package ui
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
"nerd-monitor/internal/api"
|
||
"nerd-monitor/internal/auth"
|
||
"nerd-monitor/internal/store"
|
||
"nerd-monitor/views"
|
||
)
|
||
|
||
// Handler serves UI pages.
|
||
type Handler struct {
|
||
store *store.Store
|
||
auth *auth.Manager
|
||
broadcaster interface {
|
||
AddClient(chan string)
|
||
RemoveClient(chan string)
|
||
}
|
||
}
|
||
|
||
// New creates a new UI handler.
|
||
func New(s *store.Store, a *auth.Manager) *Handler {
|
||
return &Handler{
|
||
store: s,
|
||
auth: a,
|
||
broadcaster: api.GetAPIBroadcaster(),
|
||
}
|
||
}
|
||
|
||
// Dashboard renders the dashboard page.
|
||
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming dashboard request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
slog.Debug("Rendering dashboard", "agentCount", len(h.store.GetAllAgents()), "staleAgentCount", len(h.getStaleAgents()))
|
||
agents := h.store.GetAllAgents()
|
||
staleAgents := h.getStaleAgents()
|
||
|
||
component := views.Dashboard(agents, staleAgents)
|
||
component.Render(context.Background(), w)
|
||
slog.Debug("Dashboard rendered successfully")
|
||
}
|
||
|
||
// AgentDetail renders the agent detail page.
|
||
func (h *Handler) AgentDetail(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming agent detail request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
agentID := chi.URLParam(r, "id")
|
||
slog.Debug("Retrieving agent detail", "agentID", agentID)
|
||
agent := h.store.GetAgent(agentID)
|
||
|
||
if agent == nil {
|
||
slog.Warn("Agent not found for detail view", "agentID", agentID)
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
component := views.AgentDetail(agent)
|
||
component.Render(context.Background(), w)
|
||
slog.Debug("Agent detail rendered successfully", "agentID", agentID)
|
||
}
|
||
|
||
// Login renders the login page.
|
||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming login request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
if r.Method == http.MethodGet {
|
||
slog.Debug("Rendering login page")
|
||
component := views.LoginPage("")
|
||
component.Render(context.Background(), w)
|
||
return
|
||
}
|
||
|
||
if r.Method == http.MethodPost {
|
||
username := r.FormValue("username")
|
||
password := r.FormValue("password")
|
||
|
||
slog.Debug("Attempting login", "username", username, "remoteAddr", r.RemoteAddr)
|
||
token, err := h.auth.Login(username, password)
|
||
if err != nil {
|
||
slog.Warn("Login failed - invalid credentials", "username", username, "remoteAddr", r.RemoteAddr, "error", err)
|
||
component := views.LoginPage("Invalid credentials")
|
||
component.Render(context.Background(), w)
|
||
return
|
||
}
|
||
|
||
slog.Info("Login successful", "username", username, "remoteAddr", r.RemoteAddr)
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: "session_token",
|
||
Value: token,
|
||
Path: "/",
|
||
MaxAge: 86400,
|
||
HttpOnly: true,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
return
|
||
}
|
||
|
||
slog.Warn("Invalid method for login", "method", r.Method)
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
}
|
||
|
||
// Logout handles logout.
|
||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming logout request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
if r.Method != http.MethodPost {
|
||
slog.Warn("Invalid method for logout", "method", r.Method)
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
cookie, err := r.Cookie("session_token")
|
||
if err == nil {
|
||
slog.Info("Logging out user", "remoteAddr", r.RemoteAddr)
|
||
h.auth.Logout(cookie.Value)
|
||
} else {
|
||
slog.Debug("Logout attempted without valid session", "remoteAddr", r.RemoteAddr)
|
||
}
|
||
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: "session_token",
|
||
Value: "",
|
||
Path: "/",
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
})
|
||
|
||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
}
|
||
|
||
// RemoveStaleAgents handles bulk removal of stale agents.
|
||
func (h *Handler) RemoveStaleAgents(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming remove stale agents request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
if r.Method != http.MethodPost {
|
||
slog.Warn("Invalid method for remove stale agents", "method", r.Method)
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
agentIDs := r.Form["agent_ids"]
|
||
slog.Info("Removing stale agents", "count", len(agentIDs), "agentIDs", agentIDs)
|
||
for _, id := range agentIDs {
|
||
h.store.DeleteAgent(id)
|
||
}
|
||
|
||
slog.Info("Stale agents removed successfully", "count", len(agentIDs))
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
// DeleteAgent handles single agent deletion.
|
||
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming delete agent request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
if r.Method != http.MethodPost {
|
||
slog.Warn("Invalid method for delete agent", "method", r.Method)
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
agentID := chi.URLParam(r, "id")
|
||
slog.Info("Deleting agent", "agentID", agentID)
|
||
h.store.DeleteAgent(agentID)
|
||
|
||
slog.Info("Agent deleted successfully", "agentID", agentID)
|
||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||
}
|
||
|
||
// UpdateAgentHostname handles hostname updates for agents.
|
||
func (h *Handler) UpdateAgentHostname(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("Incoming update agent hostname request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
if r.Method != http.MethodPost {
|
||
slog.Warn("Invalid method for update agent hostname", "method", r.Method)
|
||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
|
||
agentID := chi.URLParam(r, "id")
|
||
hostname := r.FormValue("hostname")
|
||
|
||
if hostname == "" {
|
||
slog.Warn("Empty hostname provided for update", "agentID", agentID)
|
||
http.Error(w, "Hostname cannot be empty", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
slog.Info("Updating agent hostname", "agentID", agentID, "newHostname", hostname)
|
||
err := h.store.UpdateHostname(agentID, hostname)
|
||
if err != nil {
|
||
slog.Warn("Failed to update hostname - agent not found", "agentID", agentID, "error", err)
|
||
http.NotFound(w, r)
|
||
return
|
||
}
|
||
|
||
slog.Info("Agent hostname updated successfully", "agentID", agentID, "hostname", hostname)
|
||
http.Redirect(w, r, "/agents/"+agentID, http.StatusSeeOther)
|
||
}
|
||
|
||
// getStaleAgents returns agents that haven't reported in 6 months.
|
||
func (h *Handler) getStaleAgents() []*store.AgentStats {
|
||
const staleThreshold = 6 * 30 * 24 * time.Hour
|
||
allAgents := h.store.GetAllAgents()
|
||
|
||
var stale []*store.AgentStats
|
||
for _, agent := range allAgents {
|
||
if time.Since(agent.LastSeen) > staleThreshold {
|
||
stale = append(stale, agent)
|
||
}
|
||
}
|
||
|
||
return stale
|
||
}
|
||
|
||
// GetDashboardTable returns HTML fragment for the agent table.
|
||
func (h *Handler) GetDashboardTable(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("HTMX dashboard table request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
agents := h.store.GetAllAgents()
|
||
|
||
w.Header().Set("Content-Type", "text/html")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
|
||
if len(agents) == 0 {
|
||
w.Write([]byte(`<div class="alert alert-info">
|
||
<strong>ℹ️ No agents connected</strong>
|
||
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
|
||
Start an agent to see its statistics appear here.
|
||
</p>
|
||
</div>`))
|
||
return
|
||
}
|
||
|
||
w.Write([]byte(`<div class="card">
|
||
<div class="card-title">Active Agents</div>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Hostname</th>
|
||
<th>CPU Usage</th>
|
||
<th>Memory</th>
|
||
<th>Disk</th>
|
||
<th>Last Seen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>`))
|
||
|
||
for _, agent := range agents {
|
||
// Determine online status (agent is online if reported within last 15 seconds)
|
||
isOnline := time.Since(agent.LastSeen) < 15*time.Second
|
||
statusClass := "status-red"
|
||
statusText := "Offline"
|
||
if isOnline {
|
||
statusClass = "status-green"
|
||
statusText = "Online"
|
||
}
|
||
|
||
row := fmt.Sprintf(`<tr>
|
||
<td>
|
||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||
<span class="status-badge %s" style="margin: 0;">%s</span>
|
||
<a href="/agents/%s" style="color: #3b82f6; text-decoration: none;">%s</a>
|
||
</div>
|
||
</td>
|
||
<td>%.1f%%</td>
|
||
<td>%s / %s</td>
|
||
<td>%s / %s</td>
|
||
<td class="timestamp">%s</td>
|
||
</tr>`,
|
||
statusClass,
|
||
statusText,
|
||
agent.ID,
|
||
agent.Hostname,
|
||
agent.CPUUsage,
|
||
formatBytes(agent.RAMUsage),
|
||
formatBytes(agent.RAMTotal),
|
||
formatBytes(agent.DiskUsage),
|
||
formatBytes(agent.DiskTotal),
|
||
agent.LastSeen.Format("2006-01-02 15:04:05"))
|
||
w.Write([]byte(row))
|
||
}
|
||
|
||
w.Write([]byte(`</tbody></table></div>`))
|
||
}
|
||
|
||
// GetAgentStats returns HTML fragment for agent statistics.
|
||
func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) {
|
||
slog.Debug("HTMX agent stats request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr)
|
||
|
||
agentID := chi.URLParam(r, "id")
|
||
if agentID == "" {
|
||
slog.Warn("Missing agent ID in stats request", "remoteAddr", r.RemoteAddr)
|
||
http.Error(w, "missing agent id", http.StatusBadRequest)
|
||
return
|
||
}
|
||
|
||
agent := h.store.GetAgent(agentID)
|
||
if agent == nil {
|
||
slog.Warn("Agent not found for stats request", "agentID", agentID)
|
||
http.Error(w, "agent not found", http.StatusNotFound)
|
||
return
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "text/html")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
|
||
// Return just the stats content that should be updated
|
||
statsHTML := fmt.Sprintf(`<div style="font-size: 2rem; margin: 1rem 0;">%.1f%%</div>
|
||
<div class="progress-bar">
|
||
<div class="progress-fill" style="width: %.1f%%"></div>
|
||
</div>
|
||
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
|
||
Last updated: %s
|
||
</div>`,
|
||
agent.CPUUsage,
|
||
agent.CPUUsage,
|
||
agent.LastSeen.Format("2006-01-02 15:04:05"))
|
||
|
||
w.Write([]byte(statsHTML))
|
||
}
|
||
|
||
// Events provides Server-Sent Events for real-time updates.
|
||
func (h *Handler) Events(w http.ResponseWriter, r *http.Request) {
|
||
slog.Info("SSE connection established", "remoteAddr", r.RemoteAddr)
|
||
|
||
// Set headers for Server-Sent Events
|
||
w.Header().Set("Content-Type", "text/event-stream")
|
||
w.Header().Set("Cache-Control", "no-cache")
|
||
w.Header().Set("Connection", "keep-alive")
|
||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||
|
||
// Create a channel for this client
|
||
clientChan := make(chan string, 10)
|
||
h.broadcaster.AddClient(clientChan)
|
||
|
||
// Clean up when client disconnects
|
||
go func() {
|
||
<-r.Context().Done()
|
||
slog.Debug("SSE client disconnected", "remoteAddr", r.RemoteAddr)
|
||
h.broadcaster.RemoveClient(clientChan)
|
||
}()
|
||
|
||
// Send initial connection event
|
||
fmt.Fprintf(w, "event: connected\ndata: {}\n\n")
|
||
w.(http.Flusher).Flush()
|
||
|
||
// Send a test event after 2 seconds
|
||
go func() {
|
||
time.Sleep(2 * time.Second)
|
||
select {
|
||
case clientChan <- "event: test\ndata: {\"message\": \"SSE working\"}\n\n":
|
||
default:
|
||
}
|
||
}()
|
||
|
||
// Listen for broadcast messages
|
||
for {
|
||
select {
|
||
case <-r.Context().Done():
|
||
return
|
||
case message := <-clientChan:
|
||
slog.Debug("Sending SSE message to client", "remoteAddr", r.RemoteAddr, "message", message)
|
||
fmt.Fprintf(w, "%s\n", message)
|
||
w.(http.Flusher).Flush()
|
||
}
|
||
}
|
||
}
|
||
|
||
// formatBytes converts bytes to human-readable format.
|
||
func formatBytes(bytes uint64) string {
|
||
const (
|
||
kb = 1024
|
||
mb = kb * 1024
|
||
gb = mb * 1024
|
||
)
|
||
|
||
switch {
|
||
case bytes >= gb:
|
||
return fmt.Sprintf("%.1f GB", float64(bytes)/float64(gb))
|
||
case bytes >= mb:
|
||
return fmt.Sprintf("%.1f MB", float64(bytes)/float64(mb))
|
||
case bytes >= kb:
|
||
return fmt.Sprintf("%.1f KB", float64(bytes)/float64(kb))
|
||
default:
|
||
return fmt.Sprintf("%d B", bytes)
|
||
}
|
||
}
|