diff --git a/AGENTS.md b/AGENTS.md index 0d9bd8c..2fcd944 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,42 +1,20 @@ # Nerd Monitor - Agent Guidelines ## Build & Test Commands +- **Generate templates**: `make templ` +- **Build**: `make build` (server + agent) or `make build-server`/`make build-agent` +- **Cross-platform**: `make build-all` +- **Clean**: `make clean` +- **Test all**: `go test ./...` +- **Test single**: `go test -run TestName ./package` +- **Format**: `gofmt -w ./cmd ./internal ./views` -```bash -# Generate Templ templates (required before building) -make templ - -# Build server and agent for current OS -make build - -# Build individual components -make build-server -make build-agent - -# Build for all platforms (Linux, macOS, Windows) -make build-all - -# Clean build artifacts -make clean -``` - -Note: This Go 1.21 project uses Templ for templating. Always run `make templ` before building. +Note: Go 1.23+ project using Templ templating. Run `make templ` before building. ## Code Style Guidelines - -**Imports**: Standard library first, then third-party packages, then internal modules. Organize alphabetically within each group. - -**Formatting & Types**: Use `gofmt` standards. Explicit struct field comments for exported types. Prefer `error` return values (no panic for recoverable errors). - -**Naming**: CamelCase for exported symbols, lowercase for unexported. Receiver names as single letters (e.g., `(m *Manager)`). Constants use ALL_CAPS_SNAKE_CASE. - -**Error Handling**: Define sentinel errors in dedicated files (e.g., `auth/errors.go`). Check errors explicitly; use `http.Error(w, "message", status)` for HTTP responses. Wrap errors with context when needed. - -**Project Structure**: -- `cmd/`: Executable entry points (server, agent) -- `internal/`: Packages (api, auth, stats, store, ui) -- `views/`: Templ templates (*.templ files) - -**Concurrency**: Use `sync.RWMutex` for shared state. Always defer lock releases. Keep critical sections small. - -**HTTP**: Use Chi router. Handlers follow `func (h *Handler) Name(w http.ResponseWriter, r *http.Request)`. Set Content-Type headers explicitly. Validate query parameters early and return 400/404 appropriately. +- **Imports**: stdlib → third-party → internal (alphabetical within groups) +- **Formatting**: `gofmt` standards, explicit exported struct comments +- **Naming**: CamelCase exports, lowercase unexported, single-letter receivers (e.g., `(h *Handler)`) +- **Error Handling**: Explicit checks, `http.Error()` for HTTP, contextual wrapping +- **Concurrency**: `sync.RWMutex` for shared state, defer lock releases, small critical sections +- **HTTP**: Chi router, `func (h *Handler) Method(w http.ResponseWriter, r *http.Request)`, set Content-Type, validate early diff --git a/agent b/agent new file mode 100755 index 0000000..b65f591 Binary files /dev/null and b/agent differ diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 38baf1a..6083f21 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -5,7 +5,7 @@ import ( "encoding/json" "flag" "fmt" - "log" + "log/slog" "net/http" "net/url" "os" @@ -25,8 +25,12 @@ func main() { ) flag.Parse() + // Set up verbose logging + slog.SetLogLoggerLevel(slog.LevelDebug) + if *server == "" { - log.Fatal("--server flag is required") + slog.Error("Server flag is required") + os.Exit(1) } // Normalize server URL (add http:// if missing) @@ -38,12 +42,13 @@ func main() { var err error id, err = generateAgentID() if err != nil { - log.Fatalf("Failed to generate agent ID: %v", err) + slog.Error("Failed to generate agent ID", "error", err) + os.Exit(1) } } - log.Printf("Starting agent with ID: %s\n", id) - log.Printf("Reporting to: %s every %v\n", *server, *interval) + slog.Info("Starting agent", "id", id) + slog.Info("Reporting configuration", "server", *server, "interval", *interval) // Initialize stats collector collector := stats.NewCollector() @@ -64,7 +69,7 @@ func main() { case <-ticker.C: reportStats(id, *server, collector) case <-sigChan: - log.Println("Agent shutting down gracefully...") + slog.Info("Agent shutting down gracefully") os.Exit(0) } } @@ -79,37 +84,39 @@ func reportStats(agentID, serverURL string, collector *stats.Collector) { stat, err := collector.Collect(hostname) if err != nil { - log.Printf("Error collecting stats: %v", err) + slog.Error("Error collecting stats", "agentID", agentID, "hostname", hostname, "error", err) return } // Marshal to JSON body, err := json.Marshal(stat) if err != nil { - log.Printf("Error marshaling stats: %v", err) + slog.Error("Error marshaling stats", "agentID", agentID, "error", err) return } // Send to server reportURL := fmt.Sprintf("%s/api/report?id=%s", serverURL, agentID) + slog.Debug("Sending stats report", "agentID", agentID, "url", reportURL, "bodySize", len(body)) resp, err := http.Post(reportURL, "application/json", bytes.NewReader(body)) if err != nil { - log.Printf("Error reporting stats: %v", err) + slog.Error("Error reporting stats", "agentID", agentID, "url", reportURL, "error", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - log.Printf("Server returned status %d", resp.StatusCode) + slog.Error("Server returned non-OK status", "agentID", agentID, "statusCode", resp.StatusCode, "url", reportURL) return } - log.Printf("Stats reported: CPU %.1f%% | RAM %s / %s | Disk %s / %s", - stat.CPUUsage, - formatBytes(stat.RAMUsage), - formatBytes(stat.RAMTotal), - formatBytes(stat.DiskUsage), - formatBytes(stat.DiskTotal), + slog.Debug("Stats reported successfully", + "agentID", agentID, + "cpu", stat.CPUUsage, + "ramUsage", formatBytes(stat.RAMUsage), + "ramTotal", formatBytes(stat.RAMTotal), + "diskUsage", formatBytes(stat.DiskUsage), + "diskTotal", formatBytes(stat.DiskTotal), ) } diff --git a/cmd/server/main.go b/cmd/server/main.go index 5dab3c7..0481ce4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -2,7 +2,7 @@ package main import ( "flag" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -24,6 +24,9 @@ func main() { ) flag.Parse() + // Set up verbose logging + slog.SetLogLoggerLevel(slog.LevelDebug) + // Initialize dependencies s := store.New() authMgr := auth.New(*username, *password) @@ -63,14 +66,15 @@ func main() { go func() { <-sigChan - log.Println("Shutting down server...") + slog.Info("Shutting down server...") server.Close() os.Exit(0) }() - log.Printf("Starting server on http://%s:%s\n", *addr, *port) - log.Printf("Login with %s / %s\n", *username, *password) + slog.Info("Starting server", "addr", *addr+":"+*port, "url", "http://"+*addr+":"+*port) + slog.Info("Login credentials", "username", *username, "password", *password) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server error: %v", err) + slog.Error("Server error", "error", err) + os.Exit(1) } } diff --git a/internal/api/api.go b/internal/api/api.go index 3dfee0b..cb1da24 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,9 +2,12 @@ package api import ( "encoding/json" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" "nerd-monitor/internal/stats" "nerd-monitor/internal/store" - "net/http" ) // Handler manages HTTP requests. @@ -19,86 +22,115 @@ func New(s *store.Store) *Handler { // ReportStats handles agent stats reports. func (h *Handler) ReportStats(w http.ResponseWriter, r *http.Request) { + slog.Debug("Incoming stats report request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr) + agentID := r.URL.Query().Get("id") if agentID == "" { + slog.Warn("Missing agent ID in stats report", "remoteAddr", r.RemoteAddr) http.Error(w, "missing agent id", http.StatusBadRequest) return } var stat stats.Stats if err := json.NewDecoder(r.Body).Decode(&stat); err != nil { + slog.Error("Invalid request body in stats report", "agentID", agentID, "error", err) http.Error(w, "invalid request body", http.StatusBadRequest) return } + slog.Debug("Updating agent stats", "agentID", agentID, "hostname", stat.Hostname, "cpu", stat.CPUUsage) h.store.UpdateAgent(agentID, &stat) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + slog.Debug("Stats report processed successfully", "agentID", agentID) } // GetAgent returns stats for a single agent. func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + slog.Debug("Incoming get agent request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr) + agentID := r.URL.Query().Get("id") if agentID == "" { + slog.Warn("Missing agent ID in get agent request", "remoteAddr", r.RemoteAddr) http.Error(w, "missing agent id", http.StatusBadRequest) return } + slog.Debug("Retrieving agent stats", "agentID", agentID) agent := h.store.GetAgent(agentID) if agent == nil { + slog.Warn("Agent not found", "agentID", agentID) http.Error(w, "agent not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(agent) + slog.Debug("Agent stats retrieved successfully", "agentID", agentID) } // ListAgents returns all agents. func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { + slog.Debug("Incoming list agents request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr) + + slog.Debug("Retrieving all agents") agents := h.store.GetAllAgents() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(agents) + slog.Debug("All agents listed successfully", "count", len(agents)) } // DeleteAgent removes an agent. 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) + agentID := r.URL.Query().Get("id") if agentID == "" { + slog.Warn("Missing agent ID in delete agent request", "remoteAddr", r.RemoteAddr) http.Error(w, "missing agent id", http.StatusBadRequest) return } + slog.Info("Deleting agent", "agentID", agentID) h.store.DeleteAgent(agentID) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) + slog.Info("Agent deleted successfully", "agentID", agentID) } // UpdateHostname updates the hostname for an agent. func (h *Handler) UpdateHostname(w http.ResponseWriter, r *http.Request) { + slog.Debug("Incoming update hostname request", "method", r.Method, "path", r.URL.Path, "remoteAddr", r.RemoteAddr) + if r.Method != http.MethodPost { + slog.Warn("Invalid method for update hostname", "method", r.Method) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - agentID := r.PathValue("id") + agentID := chi.URLParam(r, "id") if agentID == "" { + slog.Warn("Missing agent ID in update hostname request", "remoteAddr", r.RemoteAddr) http.Error(w, "missing agent id", http.StatusBadRequest) return } hostname := r.FormValue("hostname") if hostname == "" { + slog.Warn("Missing hostname in update request", "agentID", agentID) http.Error(w, "missing hostname", 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.Error(w, "agent not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "updated"}) + slog.Info("Agent hostname updated successfully", "agentID", agentID, "hostname", hostname) } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 2d34d72..54b5812 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -3,6 +3,7 @@ package auth import ( "crypto/rand" "encoding/hex" + "log/slog" "net/http" "sync" "time" @@ -36,11 +37,13 @@ func New(username, password string) *Manager { // Login validates credentials and creates a session. func (m *Manager) Login(username, password string) (string, error) { if username != m.username || password != m.password { + slog.Debug("Login failed - invalid credentials", "username", username) return "", ErrInvalidCredentials } token, err := generateToken() if err != nil { + slog.Error("Failed to generate session token", "error", err) return "", err } @@ -52,6 +55,7 @@ func (m *Manager) Login(username, password string) (string, error) { ExpiresAt: time.Now().Add(m.expiryDur), } + slog.Debug("Login successful, session created", "username", username, "token", token[:8]+"...") return token, nil } @@ -62,10 +66,17 @@ func (m *Manager) Validate(token string) bool { session, ok := m.sessions[token] if !ok { + slog.Debug("Session validation failed - token not found", "token", token[:8]+"...") return false } - return session.ExpiresAt.After(time.Now()) + if !session.ExpiresAt.After(time.Now()) { + slog.Debug("Session validation failed - token expired", "token", token[:8]+"...", "expiredAt", session.ExpiresAt) + return false + } + + slog.Debug("Session validation successful", "token", token[:8]+"...") + return true } // Logout invalidates a session. @@ -74,16 +85,24 @@ func (m *Manager) Logout(token string) { defer m.mu.Unlock() delete(m.sessions, token) + slog.Debug("Session logged out", "token", token[:8]+"...") } // Middleware returns a Chi middleware for authentication. func (m *Manager) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("session_token") - if err != nil || !m.Validate(cookie.Value) { + if err != nil { + slog.Debug("Authentication failed - no session cookie", "path", r.URL.Path, "remoteAddr", r.RemoteAddr) http.Redirect(w, r, "/login", http.StatusSeeOther) return } + if !m.Validate(cookie.Value) { + slog.Debug("Authentication failed - invalid session", "path", r.URL.Path, "remoteAddr", r.RemoteAddr) + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + slog.Debug("Authentication successful", "path", r.URL.Path, "remoteAddr", r.RemoteAddr) next.ServeHTTP(w, r) }) } diff --git a/internal/store/store.go b/internal/store/store.go index 74e6a30..7d124e9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -2,6 +2,7 @@ package store import ( "errors" + "log/slog" "sync" "time" @@ -53,6 +54,7 @@ func (s *Store) UpdateAgent(id string, stat *stats.Stats) { DiskTotal: stat.DiskTotal, LastSeen: time.Now(), } + slog.Debug("Agent stats updated", "agentID", id, "hostname", stat.Hostname, "cpu", stat.CPUUsage) } // GetAgent retrieves agent stats by ID. @@ -60,7 +62,13 @@ func (s *Store) GetAgent(id string) *AgentStats { s.mu.RLock() defer s.mu.RUnlock() - return s.agents[id] + agent := s.agents[id] + if agent != nil { + slog.Debug("Agent retrieved", "agentID", id, "hostname", agent.Hostname) + } else { + slog.Debug("Agent not found", "agentID", id) + } + return agent } // GetAllAgents returns all agents. @@ -72,6 +80,7 @@ func (s *Store) GetAllAgents() []*AgentStats { for _, agent := range s.agents { agents = append(agents, agent) } + slog.Debug("All agents retrieved", "count", len(agents)) return agents } @@ -81,6 +90,7 @@ func (s *Store) DeleteAgent(id string) { defer s.mu.Unlock() delete(s.agents, id) + slog.Debug("Agent deleted", "agentID", id) } // UpdateHostname updates the hostname for an agent. @@ -90,9 +100,11 @@ func (s *Store) UpdateHostname(id string, hostname string) error { agent, exists := s.agents[id] if !exists { + slog.Debug("Agent not found for hostname update", "agentID", id) return ErrAgentNotFound } agent.Hostname = hostname + slog.Debug("Agent hostname updated", "agentID", id, "hostname", hostname) return nil } diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index edf463b..44afa25 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -2,9 +2,11 @@ package ui import ( "context" + "log/slog" "net/http" "time" + "github.com/go-chi/chi/v5" "nerd-monitor/internal/auth" "nerd-monitor/internal/store" "nerd-monitor/views" @@ -26,30 +28,42 @@ func New(s *store.Store, a *auth.Manager) *Handler { // 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) { - agentID := r.PathValue("id") + 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 @@ -59,13 +73,16 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { 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, @@ -79,19 +96,26 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { 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{ @@ -107,53 +131,70 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { // 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 := r.PathValue("id") + 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 := r.PathValue("id") + 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) } diff --git a/server b/server new file mode 100755 index 0000000..79e5612 Binary files /dev/null and b/server differ