Files
nerd-monitor/internal/ui/handlers.go
Ducky SSH User 8cb33dbc90 feat: implement real-time updates and enhance monitoring system
- 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
2025-12-20 08:09:02 +00:00

396 lines
11 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}