5 Commits

Author SHA1 Message Date
Ducky SSH User
7d5713ed36 chore: remove build artifacts from version control
All checks were successful
Build and Release / build (push) Successful in 15s
- Remove agent and server binaries from git tracking
- These files are now properly ignored via .gitignore
2025-12-20 08:09:26 +00:00
Ducky SSH User
cc26726ddf chore: update .gitignore to exclude temporary build files
- Add agent and server binaries to .gitignore
- Remove temporary build artifacts from repository
2025-12-20 08:09:17 +00:00
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
Ducky SSH User
50dcfcdc83 Add logging and fix /agents/ route error
All checks were successful
Build and Release / build (push) Successful in 35s
2025-12-20 07:34:02 +00:00
Ducky SSH User
761b91b031 Add hostname editing feature to agent detail page
- Create enhanced agent detail template with all stats and hostname edit form
- Add UpdateHostname method to store for agent hostname updates
- Add UpdateAgentHostname handler in UI for web form submissions
- Add UpdateHostname endpoint in API handler for JSON requests
- Register new POST /agents/{id}/hostname route for hostname updates
- Improve agent detail page layout with Settings and Danger Zone sections
- Add error handling for missing agents and empty hostname values
2025-12-20 06:51:27 +00:00
13 changed files with 704 additions and 174 deletions

4
.gitignore vendored
View File

@@ -31,3 +31,7 @@ Thumbs.db
# Local configuration # Local configuration
config.local.yaml config.local.yaml
.env.*.local .env.*.local
# Temporary build files
agent
server

View File

@@ -1,42 +1,20 @@
# Nerd Monitor - Agent Guidelines # Nerd Monitor - Agent Guidelines
## Build & Test Commands ## 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 Note: Go 1.23+ project using Templ templating. Run `make templ` before building.
# 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.
## Code Style Guidelines ## Code Style Guidelines
- **Imports**: stdlib → third-party → internal (alphabetical within groups)
**Imports**: Standard library first, then third-party packages, then internal modules. Organize alphabetically within each group. - **Formatting**: `gofmt` standards, explicit exported struct comments
- **Naming**: CamelCase exports, lowercase unexported, single-letter receivers (e.g., `(h *Handler)`)
**Formatting & Types**: Use `gofmt` standards. Explicit struct field comments for exported types. Prefer `error` return values (no panic for recoverable errors). - **Error Handling**: Explicit checks, `http.Error()` for HTTP, contextual wrapping
- **Concurrency**: `sync.RWMutex` for shared state, defer lock releases, small critical sections
**Naming**: CamelCase for exported symbols, lowercase for unexported. Receiver names as single letters (e.g., `(m *Manager)`). Constants use ALL_CAPS_SNAKE_CASE. - **HTTP**: Chi router, `func (h *Handler) Method(w http.ResponseWriter, r *http.Request)`, set Content-Type, validate early
**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.

108
README.md
View File

@@ -1,22 +1,25 @@
# Nerd Monitor 📊 # Nerd Monitor 📊
A lightweight, cross-platform system monitoring solution written in Go. Monitor CPU, memory, and disk usage across multiple machines with a beautiful web dashboard. A lightweight, cross-platform system monitoring solution written in Go. Monitor CPU, memory, and disk usage across multiple machines with a beautiful web dashboard featuring instant real-time updates powered by Server-Sent Events and HTMX.
![Go](https://img.shields.io/badge/Go-1.23-blue?logo=go) ![Go](https://img.shields.io/badge/Go-1.23-blue?logo=go)
![Real-time](https://img.shields.io/badge/real--time-SSE%20%2B%20HTMX-orange?logo=lightning)
![License](https://img.shields.io/badge/license-MIT-green) ![License](https://img.shields.io/badge/license-MIT-green)
![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-important) ![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-important)
## Features ## Features
- 🖥️ **Multi-platform Support** - Deploy agents on Linux, macOS, and Windows (AMD64 & ARM64) - 🖥️ **Multi-platform Support** - Deploy agents on Linux, macOS, and Windows (AMD64 & ARM64)
- 📈 **Real-time Monitoring** - Track CPU, RAM, and Disk usage with 15-second refresh intervals - **Instant Real-time Updates** - Stats update immediately when agents report, not on fixed intervals
- 🟢 **Live Status Indicators** - See which machines are online/offline at a glance - 🟢 **Live Status Indicators** - See which machines are online/offline at a glance
- 🔒 **Secure Authentication** - Session-based admin authentication for the dashboard - 🔒 **Secure Authentication** - Session-based admin authentication for the dashboard
- 🧹 **Stale Agent Management** - Automatically detect and remove agents inactive for 6+ months - 🧹 **Stale Agent Management** - Automatically detect and remove agents inactive for 6+ months
- 📱 **Responsive Dashboard** - Beautiful, modern UI with auto-refresh - 📱 **Responsive Dashboard** - Beautiful, modern UI with seamless HTMX-powered updates
- 🚀 **Minimal Dependencies** - Only Chi router and Templ templating engine (plus Go stdlib) - 🚀 **Minimal Dependencies** - Only Chi router, Templ templating, and HTMX (plus Go stdlib)
- ⚙️ **Auto-Generation** - Agent IDs automatically generated from hostname - ⚙️ **Auto-Generation** - Agent IDs automatically generated from hostname
- 💾 **In-Memory Storage** - Fast, zero-database setup (perfect for small to medium deployments) - 💾 **In-Memory Storage** - Fast, zero-database setup (perfect for small to medium deployments)
- 📡 **Server-Sent Events** - Real-time push notifications from server to web clients
- 📝 **Structured Logging** - Comprehensive slog-based logging throughout the application
## Quick Start ## Quick Start
@@ -54,7 +57,7 @@ Binaries are created in the `bin/` directory.
-password securepassword -password securepassword
``` ```
Then access the dashboard at `http://localhost:8080` Then access the dashboard at `http://localhost:8080`. The interface provides real-time updates - stats refresh instantly when agents report new data!
### Running Agents ### Running Agents
@@ -87,7 +90,7 @@ nerd-monitor-agent.bat --server 10.0.20.80:9090 --interval 30s
### Overview Page ### Overview Page
Shows all connected agents with real-time metrics: Shows all connected agents with instant real-time metrics:
- **Status Badge** - Green (Online) or Red (Offline) - **Status Badge** - Green (Online) or Red (Offline)
- Online: Last report within 15 seconds - Online: Last report within 15 seconds
@@ -96,16 +99,18 @@ Shows all connected agents with real-time metrics:
- **Memory Usage** - Used / Total with visual progress bar - **Memory Usage** - Used / Total with visual progress bar
- **Disk Usage** - Used / Total with visual progress bar - **Disk Usage** - Used / Total with visual progress bar
- **Last Seen** - Human-readable timestamp - **Last Seen** - Human-readable timestamp
- **Live Updates** - Stats refresh immediately when agents report new data
### Agent Detail Page ### Agent Detail Page
Click any agent hostname to see detailed statistics: Click any agent hostname to see detailed statistics with live updates:
- Large CPU usage display with progress bar - Large CPU usage display with progress bar (updates in real-time)
- Memory and disk breakdowns - Memory and disk breakdowns
- Agent ID and detailed metadata - Agent ID and detailed metadata
- Last exact timestamp - Last exact timestamp
- Delete button for removing the agent - Delete button for removing the agent
- **Instant Updates** - CPU stats refresh immediately when agent reports
### Stale Agent Management ### Stale Agent Management
@@ -122,10 +127,15 @@ Agents inactive for 6+ months:
- **HTTP Router**: Chi v5 for efficient routing - **HTTP Router**: Chi v5 for efficient routing
- **Authentication**: Session-based auth with 24-hour expiry - **Authentication**: Session-based auth with 24-hour expiry
- **Storage**: In-memory concurrent-safe store (sync.RWMutex) - **Storage**: In-memory concurrent-safe store (sync.RWMutex)
- **Real-time Broadcasting**: Server-Sent Events (SSE) for instant UI updates
- **Structured Logging**: slog-based logging with debug/info levels
- **API Endpoints**: - **API Endpoints**:
- `POST /api/report` - Agent stats reporting - `POST /api/report` - Agent stats reporting (triggers real-time broadcasts)
- `GET /api/agents` - List all agents - `GET /api/agents` - List all agents
- `GET /api/agents/{id}` - Get specific agent stats - `GET /api/agents/{id}` - Get specific agent stats
- `GET /api/dashboard/table` - HTML fragment for HTMX dashboard updates
- `GET /api/agents/{id}/stats` - HTML fragment for HTMX agent detail updates
- `GET /api/events` - Server-Sent Events for real-time notifications
### Agent (`cmd/agent/`) ### Agent (`cmd/agent/`)
@@ -138,8 +148,9 @@ Agents inactive for 6+ months:
### Views (`views/`) ### Views (`views/`)
- **Templ Templates**: Type-safe HTML templating - **Templ Templates**: Type-safe HTML templating
- **HTMX Integration**: Smooth, instant updates without page refreshes
- **Server-Sent Events**: Real-time push notifications from server
- **Responsive Design**: Works on desktop, tablet, and mobile - **Responsive Design**: Works on desktop, tablet, and mobile
- **Auto-Refresh**: Dashboard refreshes every 5 seconds
- **Color-coded Status**: Visual indicators for system health - **Color-coded Status**: Visual indicators for system health
## Project Structure ## Project Structure
@@ -148,28 +159,28 @@ Agents inactive for 6+ months:
nerd-monitor/ nerd-monitor/
├── cmd/ ├── cmd/
│ ├── agent/ │ ├── agent/
│ │ └── main.go # Agent executable │ │ └── main.go # Agent executable with slog logging
│ └── server/ │ └── server/
│ └── main.go # Server executable │ └── main.go # Server executable with slog logging
├── internal/ ├── internal/
│ ├── api/ │ ├── api/
│ │ └── api.go # API handlers │ │ └── api.go # API handlers + real-time broadcaster
│ ├── auth/ │ ├── auth/
│ │ ├── middleware.go # Auth middleware │ │ ├── middleware.go # Auth middleware with detailed logging
│ │ └── errors.go # Error definitions │ │ └── errors.go # Error definitions
│ ├── stats/ │ ├── stats/
│ │ └── stats.go # System stats collection │ │ └── stats.go # System stats collection
│ ├── store/ │ ├── store/
│ │ └── store.go # In-memory agent storage │ │ └── store.go # In-memory agent storage with logging
│ └── ui/ │ └── ui/
│ └── handlers.go # Dashboard handlers │ └── handlers.go # Dashboard handlers + HTMX endpoints
├── views/ ├── views/
│ ├── layout.templ # Base layout template │ ├── layout.templ # Base layout with HTMX + SSE
│ ├── dashboard.templ # Dashboard page │ ├── dashboard.templ # Dashboard with real-time updates
│ ├── agent_detail.templ # Agent detail page │ ├── agent_detail.templ # Agent detail with live stats
│ ├── login.templ # Login page │ ├── login.templ # Login page
│ ├── components.templ # Reusable components │ ├── components.templ # Reusable components
│ └── generate.go # Templ code generation │ └── *_templ.go # Generated Templ code
├── Makefile # Build system ├── Makefile # Build system
├── go.mod / go.sum # Go dependencies ├── go.mod / go.sum # Go dependencies
├── AGENTS.md # Agent guidelines ├── AGENTS.md # Agent guidelines
@@ -231,9 +242,10 @@ Options:
- **Agent Memory**: ~8-10 MB (depending on platform) - **Agent Memory**: ~8-10 MB (depending on platform)
- **Server Memory**: Scales with connected agents (~1 MB per 1000 agents) - **Server Memory**: Scales with connected agents (~1 MB per 1000 agents)
- **Network**: Minimal bandwidth (~1 KB per report) - **Network**: Minimal bandwidth (~1 KB per report + SSE connections)
- **Dashboard Refresh**: 5 seconds (configurable) - **Real-time Updates**: Instant UI updates when agents report stats
- **Agent Reporting**: 15 seconds (configurable) - **Agent Reporting**: 15 seconds (configurable)
- **SSE Connections**: Lightweight persistent connections for real-time notifications
## Building for Specific Platforms ## Building for Specific Platforms
@@ -261,6 +273,8 @@ make build-all
- `github.com/go-chi/chi/v5` - HTTP router - `github.com/go-chi/chi/v5` - HTTP router
- `github.com/a-h/templ` - HTML templating - `github.com/a-h/templ` - HTML templating
- `github.com/shirou/gopsutil/v3` - System statistics - `github.com/shirou/gopsutil/v3` - System statistics
- **HTMX** (CDN) - Frontend interactivity and real-time updates
- **Server-Sent Events** (Go stdlib) - Real-time push notifications
### Go Standard Library ### Go Standard Library
@@ -270,6 +284,7 @@ make build-all
- `time` - Timestamps and durations - `time` - Timestamps and durations
- `os` - Hostname, signals, I/O - `os` - Hostname, signals, I/O
- `crypto/rand` - Token generation - `crypto/rand` - Token generation
- `log/slog` - Structured logging throughout application
## Troubleshooting ## Troubleshooting
@@ -279,6 +294,15 @@ make build-all
2. Check agent logs for connection errors 2. Check agent logs for connection errors
3. Ensure correct server address and port 3. Ensure correct server address and port
4. Check firewall rules allow outbound connections 4. Check firewall rules allow outbound connections
5. Check server logs for SSE broadcasting activity
### Stats not updating in real-time
1. Verify browser supports Server-Sent Events (SSE)
2. Check browser developer tools for JavaScript errors
3. Ensure `/api/events` endpoint is accessible (requires authentication)
4. Check server logs for SSE connection/disconnection messages
5. Verify HTMX library loaded correctly
### Agent shows "Offline" immediately ### Agent shows "Offline" immediately
@@ -286,6 +310,7 @@ make build-all
2. Verify server address is reachable: `ping 10.0.20.80` 2. Verify server address is reachable: `ping 10.0.20.80`
3. Check if server is listening on the correct port 3. Check if server is listening on the correct port
4. Review server logs for errors 4. Review server logs for errors
5. Note: Agents are marked offline if no report received in 15 seconds
### Can't login to dashboard ### Can't login to dashboard
@@ -293,6 +318,7 @@ make build-all
2. Try the default: `admin` / `admin` 2. Try the default: `admin` / `admin`
3. If changed, restart server with original credentials to reconfigure 3. If changed, restart server with original credentials to reconfigure
4. Check browser cookies are enabled 4. Check browser cookies are enabled
5. Clear browser cache if SSE connections seem stuck
### Windows agent window appears then closes ### Windows agent window appears then closes
@@ -312,6 +338,18 @@ make templ
go run github.com/a-h/templ/cmd/templ@latest generate go run github.com/a-h/templ/cmd/templ@latest generate
``` ```
### Viewing Logs
The application uses structured logging with `slog`. Set log level with:
```bash
# Debug logging (shows all activity)
export GOLOGLEVEL=debug
# Info logging (default, shows important events)
export GOLOGLEVEL=info
```
### Running Tests ### Running Tests
```bash ```bash
@@ -324,20 +362,42 @@ go test ./...
gofmt -w ./cmd ./internal ./views gofmt -w ./cmd ./internal ./views
``` ```
### Testing Real-time Features
To test SSE and HTMX functionality:
```bash
# Start server
./bin/nerd-monitor-server
# In another terminal, send test stats
curl -X POST "http://localhost:8080/api/report?id=test-agent" \
-H "Content-Type: application/json" \
-d '{"hostname": "test", "cpuUsage": 25.5, "ramUsage": 1000000, "ramTotal": 8000000, "diskUsage": 50000000, "diskTotal": 100000000}'
```
## Contributing ## Contributing
Contributions are welcome! Please follow the code style guidelines in `AGENTS.md`. Contributions are welcome! Please follow the code style guidelines in `AGENTS.md`.
## Recent Updates
-**Real-time Updates**: Instant UI updates via Server-Sent Events and HTMX
-**Structured Logging**: Comprehensive slog-based logging throughout
-**Enhanced Monitoring**: Better debugging and operational visibility
## Future Enhancements ## Future Enhancements
- [ ] Database persistence (SQLite/PostgreSQL) - [ ] Database persistence (SQLite/PostgreSQL)
- [ ] Alerting system (email/Slack notifications) - [ ] Alerting system (email/Slack notifications)
- [ ] Historical data / graphing - [ ] Historical data / graphing with time-series storage
- [ ] Agent grouping and tagging - [ ] Agent grouping and tagging
- [ ] Custom metric collection - [ ] Custom metric collection
- [ ] TLS/HTTPS support - [ ] TLS/HTTPS support
- [ ] Multi-tenancy - [ ] Multi-tenancy
- [ ] API documentation (Swagger) - [ ] API documentation (Swagger/OpenAPI)
- [ ] Webhook integrations
- [ ] Mobile app companion
## License ## License

View File

@@ -5,7 +5,7 @@ import (
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -25,8 +25,12 @@ func main() {
) )
flag.Parse() flag.Parse()
// Set up verbose logging
slog.SetLogLoggerLevel(slog.LevelDebug)
if *server == "" { if *server == "" {
log.Fatal("--server flag is required") slog.Error("Server flag is required")
os.Exit(1)
} }
// Normalize server URL (add http:// if missing) // Normalize server URL (add http:// if missing)
@@ -38,12 +42,13 @@ func main() {
var err error var err error
id, err = generateAgentID() id, err = generateAgentID()
if err != nil { 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) slog.Info("Starting agent", "id", id)
log.Printf("Reporting to: %s every %v\n", *server, *interval) slog.Info("Reporting configuration", "server", *server, "interval", *interval)
// Initialize stats collector // Initialize stats collector
collector := stats.NewCollector() collector := stats.NewCollector()
@@ -64,7 +69,7 @@ func main() {
case <-ticker.C: case <-ticker.C:
reportStats(id, *server, collector) reportStats(id, *server, collector)
case <-sigChan: case <-sigChan:
log.Println("Agent shutting down gracefully...") slog.Info("Agent shutting down gracefully")
os.Exit(0) os.Exit(0)
} }
} }
@@ -79,37 +84,39 @@ func reportStats(agentID, serverURL string, collector *stats.Collector) {
stat, err := collector.Collect(hostname) stat, err := collector.Collect(hostname)
if err != nil { if err != nil {
log.Printf("Error collecting stats: %v", err) slog.Error("Error collecting stats", "agentID", agentID, "hostname", hostname, "error", err)
return return
} }
// Marshal to JSON // Marshal to JSON
body, err := json.Marshal(stat) body, err := json.Marshal(stat)
if err != nil { if err != nil {
log.Printf("Error marshaling stats: %v", err) slog.Error("Error marshaling stats", "agentID", agentID, "error", err)
return return
} }
// Send to server // Send to server
reportURL := fmt.Sprintf("%s/api/report?id=%s", serverURL, agentID) 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)) resp, err := http.Post(reportURL, "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
log.Printf("Error reporting stats: %v", err) slog.Error("Error reporting stats", "agentID", agentID, "url", reportURL, "error", err)
return return
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { 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 return
} }
log.Printf("Stats reported: CPU %.1f%% | RAM %s / %s | Disk %s / %s", slog.Debug("Stats reported successfully",
stat.CPUUsage, "agentID", agentID,
formatBytes(stat.RAMUsage), "cpu", stat.CPUUsage,
formatBytes(stat.RAMTotal), "ramUsage", formatBytes(stat.RAMUsage),
formatBytes(stat.DiskUsage), "ramTotal", formatBytes(stat.RAMTotal),
formatBytes(stat.DiskTotal), "diskUsage", formatBytes(stat.DiskUsage),
"diskTotal", formatBytes(stat.DiskTotal),
) )
} }

View File

@@ -2,7 +2,7 @@ package main
import ( import (
"flag" "flag"
"log" "log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@@ -24,6 +24,9 @@ func main() {
) )
flag.Parse() flag.Parse()
// Set up verbose logging
slog.SetLogLoggerLevel(slog.LevelDebug)
// Initialize dependencies // Initialize dependencies
s := store.New() s := store.New()
authMgr := auth.New(*username, *password) authMgr := auth.New(*username, *password)
@@ -40,11 +43,20 @@ func main() {
r.Get("/login", uiHandler.Login) r.Get("/login", uiHandler.Login)
r.Post("/login", uiHandler.Login) r.Post("/login", uiHandler.Login)
// Public routes (no auth required)
r.Post("/api/report", apiHandler.ReportStats)
r.Get("/api/agents", apiHandler.ListAgents)
r.Get("/api/agents/{id}", apiHandler.GetAgent)
// Protected routes (auth required) // Protected routes (auth required)
r.Group(func(protectedRoutes chi.Router) { r.Group(func(protectedRoutes chi.Router) {
protectedRoutes.Use(authMgr.Middleware) protectedRoutes.Use(authMgr.Middleware)
protectedRoutes.Get("/", uiHandler.Dashboard) protectedRoutes.Get("/", uiHandler.Dashboard)
protectedRoutes.Get("/agents/{id}", uiHandler.AgentDetail) protectedRoutes.Get("/agents/{id}", uiHandler.AgentDetail)
protectedRoutes.Get("/api/dashboard/table", uiHandler.GetDashboardTable)
protectedRoutes.Get("/api/agents/{id}/stats", uiHandler.GetAgentStats)
protectedRoutes.Get("/api/events", uiHandler.Events)
protectedRoutes.Post("/agents/{id}/hostname", uiHandler.UpdateAgentHostname)
protectedRoutes.Post("/logout", uiHandler.Logout) protectedRoutes.Post("/logout", uiHandler.Logout)
protectedRoutes.Post("/api/agents/remove-stale", uiHandler.RemoveStaleAgents) protectedRoutes.Post("/api/agents/remove-stale", uiHandler.RemoveStaleAgents)
protectedRoutes.Post("/api/agents/{id}/delete", uiHandler.DeleteAgent) protectedRoutes.Post("/api/agents/{id}/delete", uiHandler.DeleteAgent)
@@ -62,14 +74,15 @@ func main() {
go func() { go func() {
<-sigChan <-sigChan
log.Println("Shutting down server...") slog.Info("Shutting down server...")
server.Close() server.Close()
os.Exit(0) os.Exit(0)
}() }()
log.Printf("Starting server on http://%s:%s\n", *addr, *port) slog.Info("Starting server", "addr", *addr+":"+*port, "url", "http://"+*addr+":"+*port)
log.Printf("Login with %s / %s\n", *username, *password) slog.Info("Login credentials", "username", *username, "password", *password)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err) slog.Error("Server error", "error", err)
os.Exit(1)
} }
} }

View File

@@ -2,11 +2,59 @@ package api
import ( import (
"encoding/json" "encoding/json"
"log/slog"
"net/http"
"sync"
"github.com/go-chi/chi/v5"
"nerd-monitor/internal/stats" "nerd-monitor/internal/stats"
"nerd-monitor/internal/store" "nerd-monitor/internal/store"
"net/http"
) )
// Broadcaster manages Server-Sent Events clients.
type Broadcaster struct {
clients map[chan string]bool
mu sync.RWMutex
}
var apiBroadcaster = &Broadcaster{
clients: make(map[chan string]bool),
}
// Broadcast sends a message to all connected SSE clients.
func (b *Broadcaster) Broadcast(message string) {
b.mu.RLock()
defer b.mu.RUnlock()
for clientChan := range b.clients {
select {
case clientChan <- message:
default:
// Client channel is full, skip
}
}
}
// AddClient adds a new SSE client.
func (b *Broadcaster) AddClient(clientChan chan string) {
b.mu.Lock()
defer b.mu.Unlock()
b.clients[clientChan] = true
}
// RemoveClient removes an SSE client.
func (b *Broadcaster) RemoveClient(clientChan chan string) {
b.mu.Lock()
defer b.mu.Unlock()
delete(b.clients, clientChan)
close(clientChan)
}
// GetAPIBroadcaster returns the API broadcaster instance.
func GetAPIBroadcaster() *Broadcaster {
return apiBroadcaster
}
// Handler manages HTTP requests. // Handler manages HTTP requests.
type Handler struct { type Handler struct {
store *store.Store store *store.Store
@@ -19,57 +67,121 @@ func New(s *store.Store) *Handler {
// ReportStats handles agent stats reports. // ReportStats handles agent stats reports.
func (h *Handler) ReportStats(w http.ResponseWriter, r *http.Request) { 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") agentID := r.URL.Query().Get("id")
if agentID == "" { if agentID == "" {
slog.Warn("Missing agent ID in stats report", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest) http.Error(w, "missing agent id", http.StatusBadRequest)
return return
} }
var stat stats.Stats var stat stats.Stats
if err := json.NewDecoder(r.Body).Decode(&stat); err != nil { 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) http.Error(w, "invalid request body", http.StatusBadRequest)
return return
} }
slog.Debug("Updating agent stats", "agentID", agentID, "hostname", stat.Hostname, "cpu", stat.CPUUsage)
h.store.UpdateAgent(agentID, &stat) h.store.UpdateAgent(agentID, &stat)
// Broadcast update to all connected SSE clients
message := "event: stats-update\ndata: {\"type\": \"stats-update\", \"agentId\": \"" + agentID + "\"}\n\n"
slog.Info("Broadcasting stats update", "agentID", agentID)
apiBroadcaster.Broadcast(message)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
slog.Debug("Stats report processed successfully", "agentID", agentID)
} }
// GetAgent returns stats for a single agent. // GetAgent returns stats for a single agent.
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { 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") agentID := r.URL.Query().Get("id")
if agentID == "" { if agentID == "" {
slog.Warn("Missing agent ID in get agent request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest) http.Error(w, "missing agent id", http.StatusBadRequest)
return return
} }
slog.Debug("Retrieving agent stats", "agentID", agentID)
agent := h.store.GetAgent(agentID) agent := h.store.GetAgent(agentID)
if agent == nil { if agent == nil {
slog.Warn("Agent not found", "agentID", agentID)
http.Error(w, "agent not found", http.StatusNotFound) http.Error(w, "agent not found", http.StatusNotFound)
return return
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agent) json.NewEncoder(w).Encode(agent)
slog.Debug("Agent stats retrieved successfully", "agentID", agentID)
} }
// ListAgents returns all agents. // ListAgents returns all agents.
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { 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() agents := h.store.GetAllAgents()
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agents) json.NewEncoder(w).Encode(agents)
slog.Debug("All agents listed successfully", "count", len(agents))
} }
// DeleteAgent removes an agent. // DeleteAgent removes an agent.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { 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") agentID := r.URL.Query().Get("id")
if agentID == "" { if agentID == "" {
slog.Warn("Missing agent ID in delete agent request", "remoteAddr", r.RemoteAddr)
http.Error(w, "missing agent id", http.StatusBadRequest) http.Error(w, "missing agent id", http.StatusBadRequest)
return return
} }
slog.Info("Deleting agent", "agentID", agentID)
h.store.DeleteAgent(agentID) h.store.DeleteAgent(agentID)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 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 := 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)
} }

View File

@@ -3,6 +3,7 @@ package auth
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"log/slog"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@@ -36,11 +37,13 @@ func New(username, password string) *Manager {
// Login validates credentials and creates a session. // Login validates credentials and creates a session.
func (m *Manager) Login(username, password string) (string, error) { func (m *Manager) Login(username, password string) (string, error) {
if username != m.username || password != m.password { if username != m.username || password != m.password {
slog.Debug("Login failed - invalid credentials", "username", username)
return "", ErrInvalidCredentials return "", ErrInvalidCredentials
} }
token, err := generateToken() token, err := generateToken()
if err != nil { if err != nil {
slog.Error("Failed to generate session token", "error", err)
return "", err return "", err
} }
@@ -52,6 +55,7 @@ func (m *Manager) Login(username, password string) (string, error) {
ExpiresAt: time.Now().Add(m.expiryDur), ExpiresAt: time.Now().Add(m.expiryDur),
} }
slog.Debug("Login successful, session created", "username", username, "token", token[:8]+"...")
return token, nil return token, nil
} }
@@ -62,10 +66,17 @@ func (m *Manager) Validate(token string) bool {
session, ok := m.sessions[token] session, ok := m.sessions[token]
if !ok { if !ok {
slog.Debug("Session validation failed - token not found", "token", token[:8]+"...")
return false 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. // Logout invalidates a session.
@@ -74,16 +85,24 @@ func (m *Manager) Logout(token string) {
defer m.mu.Unlock() defer m.mu.Unlock()
delete(m.sessions, token) delete(m.sessions, token)
slog.Debug("Session logged out", "token", token[:8]+"...")
} }
// Middleware returns a Chi middleware for authentication. // Middleware returns a Chi middleware for authentication.
func (m *Manager) Middleware(next http.Handler) http.Handler { func (m *Manager) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie("session_token") 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) http.Redirect(w, r, "/login", http.StatusSeeOther)
return 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) next.ServeHTTP(w, r)
}) })
} }

View File

@@ -1,12 +1,19 @@
package store package store
import ( import (
"errors"
"log/slog"
"sync" "sync"
"time" "time"
"nerd-monitor/internal/stats" "nerd-monitor/internal/stats"
) )
var (
// ErrAgentNotFound is returned when an agent is not found.
ErrAgentNotFound = errors.New("agent not found")
)
// AgentStats represents the latest stats for an agent. // AgentStats represents the latest stats for an agent.
type AgentStats struct { type AgentStats struct {
ID string ID string
@@ -47,6 +54,7 @@ func (s *Store) UpdateAgent(id string, stat *stats.Stats) {
DiskTotal: stat.DiskTotal, DiskTotal: stat.DiskTotal,
LastSeen: time.Now(), LastSeen: time.Now(),
} }
slog.Debug("Agent stats updated", "agentID", id, "hostname", stat.Hostname, "cpu", stat.CPUUsage)
} }
// GetAgent retrieves agent stats by ID. // GetAgent retrieves agent stats by ID.
@@ -54,7 +62,13 @@ func (s *Store) GetAgent(id string) *AgentStats {
s.mu.RLock() s.mu.RLock()
defer s.mu.RUnlock() 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. // GetAllAgents returns all agents.
@@ -66,6 +80,7 @@ func (s *Store) GetAllAgents() []*AgentStats {
for _, agent := range s.agents { for _, agent := range s.agents {
agents = append(agents, agent) agents = append(agents, agent)
} }
slog.Debug("All agents retrieved", "count", len(agents))
return agents return agents
} }
@@ -75,4 +90,21 @@ func (s *Store) DeleteAgent(id string) {
defer s.mu.Unlock() defer s.mu.Unlock()
delete(s.agents, id) delete(s.agents, id)
slog.Debug("Agent deleted", "agentID", id)
}
// UpdateHostname updates the hostname for an agent.
func (s *Store) UpdateHostname(id string, hostname string) error {
s.mu.Lock()
defer s.mu.Unlock()
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
} }

View File

@@ -2,9 +2,13 @@ package ui
import ( import (
"context" "context"
"fmt"
"log/slog"
"net/http" "net/http"
"time" "time"
"github.com/go-chi/chi/v5"
"nerd-monitor/internal/api"
"nerd-monitor/internal/auth" "nerd-monitor/internal/auth"
"nerd-monitor/internal/store" "nerd-monitor/internal/store"
"nerd-monitor/views" "nerd-monitor/views"
@@ -12,44 +16,61 @@ import (
// Handler serves UI pages. // Handler serves UI pages.
type Handler struct { type Handler struct {
store *store.Store store *store.Store
auth *auth.Manager auth *auth.Manager
broadcaster interface {
AddClient(chan string)
RemoveClient(chan string)
}
} }
// New creates a new UI handler. // New creates a new UI handler.
func New(s *store.Store, a *auth.Manager) *Handler { func New(s *store.Store, a *auth.Manager) *Handler {
return &Handler{ return &Handler{
store: s, store: s,
auth: a, auth: a,
broadcaster: api.GetAPIBroadcaster(),
} }
} }
// Dashboard renders the dashboard page. // Dashboard renders the dashboard page.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) { 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() agents := h.store.GetAllAgents()
staleAgents := h.getStaleAgents() staleAgents := h.getStaleAgents()
component := views.Dashboard(agents, staleAgents) component := views.Dashboard(agents, staleAgents)
component.Render(context.Background(), w) component.Render(context.Background(), w)
slog.Debug("Dashboard rendered successfully")
} }
// AgentDetail renders the agent detail page. // AgentDetail renders the agent detail page.
func (h *Handler) AgentDetail(w http.ResponseWriter, r *http.Request) { 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) agent := h.store.GetAgent(agentID)
if agent == nil { if agent == nil {
slog.Warn("Agent not found for detail view", "agentID", agentID)
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
component := views.AgentDetail(agent) component := views.AgentDetail(agent)
component.Render(context.Background(), w) component.Render(context.Background(), w)
slog.Debug("Agent detail rendered successfully", "agentID", agentID)
} }
// Login renders the login page. // Login renders the login page.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { 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 { if r.Method == http.MethodGet {
slog.Debug("Rendering login page")
component := views.LoginPage("") component := views.LoginPage("")
component.Render(context.Background(), w) component.Render(context.Background(), w)
return return
@@ -59,13 +80,16 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username") username := r.FormValue("username")
password := r.FormValue("password") password := r.FormValue("password")
slog.Debug("Attempting login", "username", username, "remoteAddr", r.RemoteAddr)
token, err := h.auth.Login(username, password) token, err := h.auth.Login(username, password)
if err != nil { if err != nil {
slog.Warn("Login failed - invalid credentials", "username", username, "remoteAddr", r.RemoteAddr, "error", err)
component := views.LoginPage("Invalid credentials") component := views.LoginPage("Invalid credentials")
component.Render(context.Background(), w) component.Render(context.Background(), w)
return return
} }
slog.Info("Login successful", "username", username, "remoteAddr", r.RemoteAddr)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: "session_token", Name: "session_token",
Value: token, Value: token,
@@ -79,19 +103,26 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
return return
} }
slog.Warn("Invalid method for login", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
} }
// Logout handles logout. // Logout handles logout.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { 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 { if r.Method != http.MethodPost {
slog.Warn("Invalid method for logout", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
cookie, err := r.Cookie("session_token") cookie, err := r.Cookie("session_token")
if err == nil { if err == nil {
slog.Info("Logging out user", "remoteAddr", r.RemoteAddr)
h.auth.Logout(cookie.Value) h.auth.Logout(cookie.Value)
} else {
slog.Debug("Logout attempted without valid session", "remoteAddr", r.RemoteAddr)
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
@@ -107,32 +138,73 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
// RemoveStaleAgents handles bulk removal of stale agents. // RemoveStaleAgents handles bulk removal of stale agents.
func (h *Handler) RemoveStaleAgents(w http.ResponseWriter, r *http.Request) { 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 { if r.Method != http.MethodPost {
slog.Warn("Invalid method for remove stale agents", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
agentIDs := r.Form["agent_ids"] agentIDs := r.Form["agent_ids"]
slog.Info("Removing stale agents", "count", len(agentIDs), "agentIDs", agentIDs)
for _, id := range agentIDs { for _, id := range agentIDs {
h.store.DeleteAgent(id) h.store.DeleteAgent(id)
} }
slog.Info("Stale agents removed successfully", "count", len(agentIDs))
http.Redirect(w, r, "/", http.StatusSeeOther) http.Redirect(w, r, "/", http.StatusSeeOther)
} }
// DeleteAgent handles single agent deletion. // DeleteAgent handles single agent deletion.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) { 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 { if r.Method != http.MethodPost {
slog.Warn("Invalid method for delete agent", "method", r.Method)
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return return
} }
agentID := r.PathValue("id") agentID := chi.URLParam(r, "id")
slog.Info("Deleting agent", "agentID", agentID)
h.store.DeleteAgent(agentID) h.store.DeleteAgent(agentID)
slog.Info("Agent deleted successfully", "agentID", agentID)
http.Redirect(w, r, "/", http.StatusSeeOther) 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. // getStaleAgents returns agents that haven't reported in 6 months.
func (h *Handler) getStaleAgents() []*store.AgentStats { func (h *Handler) getStaleAgents() []*store.AgentStats {
const staleThreshold = 6 * 30 * 24 * time.Hour const staleThreshold = 6 * 30 * 24 * time.Hour
@@ -147,3 +219,177 @@ func (h *Handler) getStaleAgents() []*store.AgentStats {
return stale 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)
}
}

View File

@@ -9,29 +9,37 @@ templ AgentDetail(agent *store.AgentStats) {
@BaseLayout(agent.Hostname, agentDetailContent(agent)) @BaseLayout(agent.Hostname, agentDetailContent(agent))
} }
templ AgentDetailContent(agent *store.AgentStats) {
@agentDetailContent(agent)
}
templ agentDetailContent(agent *store.AgentStats) { templ agentDetailContent(agent *store.AgentStats) {
<a href="/" class="back-link">← Back to Dashboard</a> <a href="/" class="back-link">← Back to Dashboard</a>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;"> <div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<h2 style="margin: 0;">{ agent.Hostname }</h2> <h2 style="margin: 0;">{ agent.Hostname }</h2>
@AgentStatusBadge(agent.LastSeen) @AgentStatusBadge(agent.LastSeen)
</div> </div>
<!-- CPU Usage Card -->
<div class="grid" style="grid-template-columns: 1fr;"> <div class="grid" style="grid-template-columns: 1fr;">
<div class="card"> <div class="card">
<div class="card-title">CPU Usage</div> <div class="card-title">CPU Usage</div>
<div class="metric-value" style="font-size: 2rem; margin: 1rem 0;"> <div id="cpu-stats" hx-get={ templ.SafeURL("/api/agents/" + agent.ID + "/stats") } hx-trigger="stats-update" hx-swap="innerHTML">
{ FormatPercent(agent.CPUUsage) } <div class="metric-value" style="font-size: 2rem; margin: 1rem 0;">
</div> { FormatPercent(agent.CPUUsage) }
<div class="progress-bar"> </div>
<div class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div> <div class="progress-bar">
</div> <div class="progress-fill" class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div>
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;"> </div>
Last updated: { FormatTime(agent.LastSeen) } <div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
Last updated: { FormatTime(agent.LastSeen) }
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Memory and Disk Usage Cards -->
<div class="grid" style="margin-top: 1.5rem;"> <div class="grid" style="margin-top: 1.5rem;">
<div class="card"> <div class="card">
<div class="card-title">Memory Usage</div> <div class="card-title">Memory Usage</div>
@@ -43,24 +51,49 @@ templ agentDetailContent(agent *store.AgentStats) {
</div> </div>
</div> </div>
<!-- Agent Information and Settings -->
<div class="card" style="margin-top: 1.5rem;"> <div class="card" style="margin-top: 1.5rem;">
<div class="card-title">Agent Information</div> <div class="card-title">Agent Information</div>
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">Agent ID</span> <span class="metric-label">Agent ID</span>
<span class="metric-value" style="font-family: monospace; font-size: 0.875rem;">{ agent.ID }</span> <span class="metric-value" style="font-family: monospace; font-size: 0.875rem;">{ agent.ID }</span>
</div> </div>
<div class="metric-row">
<span class="metric-label">Hostname</span>
<span class="metric-value">{ agent.Hostname }</span>
</div>
<div class="metric-row"> <div class="metric-row">
<span class="metric-label">Last Seen</span> <span class="metric-label">Last Seen</span>
<span class="metric-value">{ agent.LastSeen.Format("2006-01-02 15:04:05") }</span> <span class="metric-value">{ agent.LastSeen.Format("2006-01-02 15:04:05") }</span>
</div> </div>
</div> </div>
<div class="btn-group" style="margin-top: 1.5rem;"> <!-- Hostname Settings -->
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/api/agents/%s/delete", agent.ID)) } style="margin: 0;" onsubmit="return confirm('Are you sure you want to remove this agent?');"> <div class="card" style="margin-top: 1.5rem;">
<div class="card-title">Settings</div>
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/agents/%s/hostname", agent.ID)) } style="display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<label for="hostname" style="font-size: 0.875rem; font-weight: 500; color: #cbd5e1;">
Hostname
</label>
<input
type="text"
id="hostname"
name="hostname"
value={ agent.Hostname }
required
style="padding: 0.5rem 0.75rem; background-color: #1e293b; border: 1px solid #475569; border-radius: 0.375rem; color: #e2e8f0; font-size: 0.875rem;"
/>
</div>
<button type="submit" class="btn btn-primary" style="align-self: flex-start;">
Update Hostname
</button>
</form>
</div>
<!-- Danger Zone -->
<div class="card" style="margin-top: 1.5rem; border-left: 3px solid #ef4444;">
<div class="card-title" style="color: #ef4444;">Danger Zone</div>
<p style="margin: 0 0 1rem 0; color: #94a3b8; font-size: 0.875rem;">
Deleting this agent will remove all its data and history.
</p>
<form method="POST" action={ templ.SafeURL(fmt.Sprintf("/api/agents/%s/delete", agent.ID)) } style="margin: 0;" onsubmit="return confirm('Are you sure you want to remove this agent? This action cannot be undone.');">
<button type="submit" class="btn btn-danger">Delete Agent</button> <button type="submit" class="btn btn-danger">Delete Agent</button>
</form> </form>
</div> </div>

View File

@@ -8,74 +8,80 @@ templ Dashboard(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
@BaseLayout("Dashboard", dashboardContent(agents, staleAgents)) @BaseLayout("Dashboard", dashboardContent(agents, staleAgents))
} }
templ DashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
@dashboardContent(agents, staleAgents)
}
templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) { templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
<h2 style="margin-bottom: 1rem;">Agent Status Overview</h2> <h2 style="margin-bottom: 1rem;">Agent Status Overview</h2>
@StaleAgentAlert(len(staleAgents), nil) @StaleAgentAlert(len(staleAgents), nil)
if len(agents) == 0 { <div id="agent-table-container" hx-get="/api/dashboard/table" hx-trigger="stats-update" hx-swap="innerHTML">
<div class="alert alert-info"> if len(agents) == 0 {
<strong> No agents connected</strong> <div class="alert alert-info">
<p style="margin-top: 0.5rem; font-size: 0.875rem;"> <strong> No agents connected</strong>
Start an agent to see its statistics appear here. <p style="margin-top: 0.5rem; font-size: 0.875rem;">
</p> Start an agent to see its statistics appear here.
</div> </p>
} else {
<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 {
@AgentRow(agent.ID, agent.Hostname, agent.CPUUsage, agent.RAMUsage, agent.RAMTotal, agent.DiskUsage, agent.DiskTotal, agent.LastSeen)
}
</tbody>
</table>
</div>
}
if len(staleAgents) > 0 {
<div class="card" style="margin-top: 2rem; border-color: #d97706;">
<div class="card-title" style="color: #f59e0b;">
Stale Agents ({ len(staleAgents) })
</div>
<form method="POST" action="/api/agents/remove-stale" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th style="width: 30px;"><input type="checkbox" id="select-all" onchange="toggleAll(this)" /></th>
<th>Hostname</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
for _, agent := range staleAgents {
<tr>
<td><input type="checkbox" name="agent_ids" value={ agent.ID } class="agent-checkbox" /></td>
<td>{ agent.Hostname }</td>
<td class="timestamp">{ FormatTime(agent.LastSeen) }</td>
</tr>
}
</tbody>
</table>
<div class="btn-group">
<button type="submit" class="btn btn-danger" style="margin-top: 1rem;">Remove Selected</button>
</div> </div>
</form> } else {
<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 {
@AgentRow(agent.ID, agent.Hostname, agent.CPUUsage, agent.RAMUsage, agent.RAMTotal, agent.DiskUsage, agent.DiskTotal, agent.LastSeen)
}
</tbody>
</table>
</div>
}
</div> </div>
<script>
function toggleAll(checkbox) { if len(staleAgents) > 0 {
const checkboxes = document.querySelectorAll('.agent-checkbox'); <div class="card" style="margin-top: 2rem; border-color: #d97706;">
checkboxes.forEach(cb => cb.checked = checkbox.checked); <div class="card-title" style="color: #f59e0b;">
Stale Agents ({ len(staleAgents) })
</div>
<form method="POST" action="/api/agents/remove-stale" style="margin-top: 1rem;">
<table>
<thead>
<tr>
<th style="width: 30px;"><input type="checkbox" id="select-all" onchange="toggleAll(this)" /></th>
<th>Hostname</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody>
for _, agent := range staleAgents {
<tr>
<td><input type="checkbox" name="agent_ids" value={ agent.ID } class="agent-checkbox" /></td>
<td>{ agent.Hostname }</td>
<td class="timestamp">{ FormatTime(agent.LastSeen) }</td>
</tr>
}
</tbody>
</table>
<div class="btn-group">
<button type="submit" class="btn btn-danger" style="margin-top: 1rem;">Remove Selected</button>
</div>
</form>
</div>
} }
</script> <script>
function toggleAll(checkbox) {
const checkboxes = document.querySelectorAll('.agent-checkbox');
checkboxes.forEach(cb => cb.checked = checkbox.checked);
} }
</script>
} }

View File

@@ -1,3 +0,0 @@
//go:generate templ generate
package views

View File

@@ -6,8 +6,31 @@ templ BaseLayout(title string, content templ.Component) {
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="5" />
<title>{ title } - Nerd Monitor</title> <title>{ title } - Nerd Monitor</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Connect to Server-Sent Events for real-time updates
const eventSource = new EventSource('/api/events');
eventSource.onmessage = function(event) {
console.log('SSE message received:', event.data);
};
eventSource.addEventListener('stats-update', function(event) {
console.log('Stats update event:', event.data);
// Trigger HTMX updates using HTMX's API
const agentTable = document.getElementById('agent-table-container');
const cpuStats = document.getElementById('cpu-stats');
if (agentTable) htmx.trigger(agentTable, 'stats-update');
if (cpuStats) htmx.trigger(cpuStats, 'stats-update');
});
eventSource.addEventListener('connected', function(event) {
console.log('SSE connected');
});
eventSource.onerror = function(event) {
console.log('SSE error:', event);
};
});
</script>
<style> <style>
* { * {
margin: 0; margin: 0;