Initial commit: Nerd Monitor - Cross-platform system monitoring application

Features:
- Multi-platform agents (Linux, macOS, Windows - AMD64 & ARM64)
- Real-time CPU, RAM, and disk usage monitoring
- Responsive web dashboard with live status indicators
- Session-based authentication with secure credentials
- Stale agent detection and removal (6+ months inactive)
- Auto-refresh dashboard (5 second intervals)
- 15-second agent reporting intervals
- Auto-generated agent IDs from hostnames
- In-memory storage (zero database setup)
- Minimal dependencies (Chi router + Templ templating)

Project Structure:
- cmd/: Agent and Server executables
- internal/: API, Auth, Stats, Storage, and UI packages
- views/: Templ templates for dashboard UI
- Makefile: Build automation for all platforms

Ready for deployment with comprehensive documentation:
- README.md: Full project documentation
- QUICKSTART.md: Getting started guide
- AGENTS.md: Development guidelines
This commit is contained in:
Ducky SSH User
2025-12-20 04:51:12 +00:00
commit 765590a1a8
21 changed files with 2144 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# Build artifacts
/bin/
/dist/
*.exe
*.dll
*.so
*.dylib
# Generated Templ files
views/*_templ.go
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
*.iml
# Go
vendor/
.env
.env.local
# OS
Thumbs.db
.DS_Store
*.log
# Local configuration
config.local.yaml
.env.*.local

42
AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# Nerd Monitor - Agent Guidelines
## Build & Test Commands
```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.
## 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.

55
Makefile Normal file
View File

@@ -0,0 +1,55 @@
.PHONY: build build-server build-agent build-all clean templ help
# Default target
help:
@echo "Nerd Monitor - Build Commands"
@echo ""
@echo " make build - Build server and agent for current OS"
@echo " make build-server - Build server executable"
@echo " make build-agent - Build agent executable"
@echo " make build-all - Build for all platforms (Linux, macOS, Windows)"
@echo " make templ - Generate code from Templ templates"
@echo " make clean - Remove build artifacts"
@echo ""
build: templ build-server build-agent
templ:
@echo "Generating Templ templates..."
@go run github.com/a-h/templ/cmd/templ@latest generate
build-server: templ
@echo "Building server for $(GOOS)/$(GOARCH)..."
@mkdir -p bin
go build -o bin/nerd-monitor-server ./cmd/server
build-agent:
@echo "Building agent for $(GOOS)/$(GOARCH)..."
@mkdir -p bin
go build -o bin/nerd-monitor-agent ./cmd/agent
build-all: templ
@echo "Building for all platforms..."
@mkdir -p bin
@echo " Linux AMD64..."
@GOOS=linux GOARCH=amd64 go build -o bin/nerd-monitor-server-linux-amd64 ./cmd/server
@GOOS=linux GOARCH=amd64 go build -o bin/nerd-monitor-agent-linux-amd64 ./cmd/agent
@echo " Linux ARM64..."
@GOOS=linux GOARCH=arm64 go build -o bin/nerd-monitor-server-linux-arm64 ./cmd/server
@GOOS=linux GOARCH=arm64 go build -o bin/nerd-monitor-agent-linux-arm64 ./cmd/agent
@echo " macOS AMD64..."
@GOOS=darwin GOARCH=amd64 go build -o bin/nerd-monitor-server-darwin-amd64 ./cmd/server
@GOOS=darwin GOARCH=amd64 go build -o bin/nerd-monitor-agent-darwin-amd64 ./cmd/agent
@echo " macOS ARM64..."
@GOOS=darwin GOARCH=arm64 go build -o bin/nerd-monitor-server-darwin-arm64 ./cmd/server
@GOOS=darwin GOARCH=arm64 go build -o bin/nerd-monitor-agent-darwin-arm64 ./cmd/agent
@echo " Windows AMD64..."
@GOOS=windows GOARCH=amd64 go build -o bin/nerd-monitor-server-windows-amd64.exe ./cmd/server
@GOOS=windows GOARCH=amd64 go build -o bin/nerd-monitor-agent-windows-amd64.exe ./cmd/agent
@echo "Build complete!"
@ls -lh bin/
clean:
@echo "Cleaning build artifacts..."
@rm -rf bin/
@echo "Clean complete!"

136
QUICKSTART.md Normal file
View File

@@ -0,0 +1,136 @@
# Nerd Monitor - Quick Start Guide
## Building
```bash
# Build for current OS
make build
# Build for all platforms (Linux, macOS, Windows)
make build-all
# Clean build artifacts
make clean
```
## Running the Server
```bash
# Start server with default credentials (admin/admin)
./bin/nerd-monitor-server
# Start server on custom address/port
./bin/nerd-monitor-server -addr 0.0.0.0 -port 8080 -username myuser -password mypass
# Access the dashboard at: http://localhost:8080
```
## Running Agents
### Linux / macOS
```bash
# Default: connects to localhost:8080 every 15 seconds
./bin/nerd-monitor-agent
# Custom server
./bin/nerd-monitor-agent --server 10.0.20.80
# Custom server with port
./bin/nerd-monitor-agent --server 10.0.20.80:9090
# Custom reporting interval
./bin/nerd-monitor-agent --server myserver --interval 30s
```
### Windows
On Windows, use the batch wrapper to run the agent in the background (it won't show a terminal window):
```batch
# Default: connects to localhost:8080 every 15 seconds
nerd-monitor-agent.bat
# Custom server
nerd-monitor-agent.bat --server 10.0.20.80
# Custom server with port
nerd-monitor-agent.bat --server 10.0.20.80:9090
# Custom interval
nerd-monitor-agent.bat --server myserver --interval 30s
```
**Note**: The batch file automatically hides the console window and runs the agent as a background process.
## Dashboard Features
### Agent Status Indicators
- **Green "Online"** - Agent reported stats within the last 2 minutes
- **Red "Offline"** - Agent hasn't reported for more than 2 minutes
### Dashboard Overview
- Real-time agent status with online/offline indicators
- CPU usage percentage
- Memory (RAM) usage and total
- Disk usage and total
- Last seen timestamp
- All values are human-readable (B, KB, MB, GB, TB)
### Agent Detail Page
Click on any hostname to see detailed stats for that agent:
- CPU usage with progress bar
- Memory usage with progress bar
- Disk usage with progress bar
- Agent ID and detailed information
### Stale Agent Management
Agents that haven't reported for 6+ months show a warning. You can:
- See a list of all stale agents
- Bulk select and remove them
- Keep your dashboard clean
## Auto-Generation Features
### Agent ID
Agent IDs are automatically generated from the machine hostname (e.g., `my-laptop` or `DLO-DEV-001`)
### Auto-Refresh Dashboard
The dashboard automatically refreshes every 10 seconds to show the latest stats
## Connection Recovery
The agent automatically retries connections if the server is unavailable. It will keep attempting to report stats at the configured interval.
## Default Credentials
- **Username**: admin
- **Password**: admin
Change these when starting the server:
```bash
./bin/nerd-monitor-server -username myuser -password mysecurepass
```
## Architecture
- **Server**: Web UI, API endpoint for agent stats, in-memory storage
- **Agent**: Lightweight monitoring agent that reports CPU, RAM, and Disk usage
- **No Database**: All data stored in server memory (resets on restart)
- **Minimal Dependencies**: Only Chi (router) and Templ (templating) besides Go stdlib
## Troubleshooting
### Agent shows "Offline" in dashboard
- Check if the agent process is running
- Verify the server address is correct
- Check network connectivity between agent and server
- Review agent logs for connection errors
### Windows agent terminal window appears then closes
- Use the `nerd-monitor-agent.bat` wrapper instead of the `.exe` directly
- The batch file handles running the agent in the background
### Can't connect to server
- Verify server is running: `http://localhost:8080`
- Check firewall rules allow the agent port
- Ensure correct server address and port are specified

354
README.md Normal file
View File

@@ -0,0 +1,354 @@
# 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.
![Go](https://img.shields.io/badge/Go-1.23-blue?logo=go)
![License](https://img.shields.io/badge/license-MIT-green)
![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS%20%7C%20Windows-important)
## Features
- 🖥️ **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
- 🟢 **Live Status Indicators** - See which machines are online/offline at a glance
- 🔒 **Secure Authentication** - Session-based admin authentication for the dashboard
- 🧹 **Stale Agent Management** - Automatically detect and remove agents inactive for 6+ months
- 📱 **Responsive Dashboard** - Beautiful, modern UI with auto-refresh
- 🚀 **Minimal Dependencies** - Only Chi router and Templ templating engine (plus Go stdlib)
- ⚙️ **Auto-Generation** - Agent IDs automatically generated from hostname
- 💾 **In-Memory Storage** - Fast, zero-database setup (perfect for small to medium deployments)
## Quick Start
### Prerequisites
- Go 1.23+ (for building)
- Port 8080 available (configurable)
### Building
```bash
# Build for current OS
make build
# Build for all platforms (Linux, macOS, Windows)
make build-all
# Clean build artifacts
make clean
```
Binaries are created in the `bin/` directory.
### Running the Server
```bash
# Start with default credentials (admin/admin)
./bin/nerd-monitor-server
# Custom configuration
./bin/nerd-monitor-server \
-addr 0.0.0.0 \
-port 8080 \
-username admin \
-password securepassword
```
Then access the dashboard at `http://localhost:8080`
### Running Agents
#### Linux / macOS
```bash
# Connect to local server with default port
./bin/nerd-monitor-agent --server 10.0.20.80
# With custom port
./bin/nerd-monitor-agent --server 10.0.20.80:9090
# With custom reporting interval (default: 15s)
./bin/nerd-monitor-agent --server myserver:8080 --interval 30s
```
#### Windows
Use the batch wrapper to run agents as a background process (no visible terminal):
```batch
REM Run in background with custom server
nerd-monitor-agent.bat --server 10.0.20.80
REM With custom port
nerd-monitor-agent.bat --server 10.0.20.80:9090 --interval 30s
```
## Dashboard
### Overview Page
Shows all connected agents with real-time metrics:
- **Status Badge** - Green (Online) or Red (Offline)
- Online: Last report within 15 seconds
- Offline: No report for 15+ seconds
- **CPU Usage** - Percentage (0-100%)
- **Memory Usage** - Used / Total with visual progress bar
- **Disk Usage** - Used / Total with visual progress bar
- **Last Seen** - Human-readable timestamp
### Agent Detail Page
Click any agent hostname to see detailed statistics:
- Large CPU usage display with progress bar
- Memory and disk breakdowns
- Agent ID and detailed metadata
- Last exact timestamp
- Delete button for removing the agent
### Stale Agent Management
Agents inactive for 6+ months:
- Display warning banner on dashboard
- Show list of stale agents
- Bulk select and remove functionality
- Keep dashboard clean and organized
## Architecture
### Server (`cmd/server/`)
- **HTTP Router**: Chi v5 for efficient routing
- **Authentication**: Session-based auth with 24-hour expiry
- **Storage**: In-memory concurrent-safe store (sync.RWMutex)
- **API Endpoints**:
- `POST /api/report` - Agent stats reporting
- `GET /api/agents` - List all agents
- `GET /api/agents/{id}` - Get specific agent stats
### Agent (`cmd/agent/`)
- **Stats Collection**: Uses gopsutil for cross-platform stats
- **Auto-ID Generation**: Hostname-based ID generation
- **Reporting**: Configurable interval (default 15 seconds)
- **Resilience**: Automatic retry on connection failure
- **Graceful Shutdown**: Handles SIGTERM and SIGINT
### Views (`views/`)
- **Templ Templates**: Type-safe HTML templating
- **Responsive Design**: Works on desktop, tablet, and mobile
- **Auto-Refresh**: Dashboard refreshes every 5 seconds
- **Color-coded Status**: Visual indicators for system health
## Project Structure
```
nerd-monitor/
├── cmd/
│ ├── agent/
│ │ └── main.go # Agent executable
│ └── server/
│ └── main.go # Server executable
├── internal/
│ ├── api/
│ │ └── api.go # API handlers
│ ├── auth/
│ │ ├── middleware.go # Auth middleware
│ │ └── errors.go # Error definitions
│ ├── stats/
│ │ └── stats.go # System stats collection
│ ├── store/
│ │ └── store.go # In-memory agent storage
│ └── ui/
│ └── handlers.go # Dashboard handlers
├── views/
│ ├── layout.templ # Base layout template
│ ├── dashboard.templ # Dashboard page
│ ├── agent_detail.templ # Agent detail page
│ ├── login.templ # Login page
│ ├── components.templ # Reusable components
│ └── generate.go # Templ code generation
├── Makefile # Build system
├── go.mod / go.sum # Go dependencies
├── AGENTS.md # Agent guidelines
├── QUICKSTART.md # Quick start guide
└── README.md # This file
```
## Code Style Guidelines
Following the guidelines in `AGENTS.md`:
- **Imports**: Standard library → third-party → internal (alphabetical)
- **Formatting**: gofmt standards
- **Naming**: CamelCase for exports, lowercase for unexported
- **Error Handling**: Explicit checks, contextual wrapping
- **Concurrency**: sync.RWMutex for shared state, always defer releases
- **HTTP**: Chi router with proper status codes and Content-Type headers
## Configuration
### Server Flags
```bash
./bin/nerd-monitor-server [OPTIONS]
Options:
-addr string
Server address (default "localhost")
-port string
Server port (default "8080")
-username string
Admin username (default "admin")
-password string
Admin password (default "admin")
```
### Agent Flags
```bash
./bin/nerd-monitor-agent [OPTIONS]
Options:
-server string
Server URL (required)
-interval duration
Reporting interval (default 15s)
-id string
Agent ID (auto-generated from hostname if empty)
```
## Default Credentials
- **Username**: `admin`
- **Password**: `admin`
⚠️ **Security**: Change these credentials in production!
## Performance
- **Agent Memory**: ~8-10 MB (depending on platform)
- **Server Memory**: Scales with connected agents (~1 MB per 1000 agents)
- **Network**: Minimal bandwidth (~1 KB per report)
- **Dashboard Refresh**: 5 seconds (configurable)
- **Agent Reporting**: 15 seconds (configurable)
## Building for Specific Platforms
```bash
# Linux AMD64
GOOS=linux GOARCH=amd64 go build -o bin/nerd-monitor-agent-linux-amd64 ./cmd/agent
# macOS ARM64 (Apple Silicon)
GOOS=darwin GOARCH=arm64 go build -o bin/nerd-monitor-agent-darwin-arm64 ./cmd/agent
# Windows AMD64
GOOS=windows GOARCH=amd64 go build -o bin/nerd-monitor-agent-windows-amd64.exe ./cmd/agent
```
Or use the Makefile:
```bash
make build-all
```
## Dependencies
### Production
- `github.com/go-chi/chi/v5` - HTTP router
- `github.com/a-h/templ` - HTML templating
- `github.com/shirou/gopsutil/v3` - System statistics
### Go Standard Library
- `encoding/json` - JSON marshaling
- `net/http` - HTTP server and client
- `sync` - Concurrent access (RWMutex)
- `time` - Timestamps and durations
- `os` - Hostname, signals, I/O
- `crypto/rand` - Token generation
## Troubleshooting
### Agent not appearing in dashboard
1. Verify server is running: `http://localhost:8080`
2. Check agent logs for connection errors
3. Ensure correct server address and port
4. Check firewall rules allow outbound connections
### Agent shows "Offline" immediately
1. Check network connectivity between agent and server
2. Verify server address is reachable: `ping 10.0.20.80`
3. Check if server is listening on the correct port
4. Review server logs for errors
### Can't login to dashboard
1. Verify you're using correct credentials
2. Try the default: `admin` / `admin`
3. If changed, restart server with original credentials to reconfigure
4. Check browser cookies are enabled
### Windows agent window appears then closes
1. Use `nerd-monitor-agent.bat` instead of `.exe` directly
2. The batch wrapper handles background execution
3. Check task manager to verify agent process is running
## Development
### Regenerating Templates
When modifying `.templ` files:
```bash
make templ
# or
go run github.com/a-h/templ/cmd/templ@latest generate
```
### Running Tests
```bash
go test ./...
```
### Code Formatting
```bash
gofmt -w ./cmd ./internal ./views
```
## Contributing
Contributions are welcome! Please follow the code style guidelines in `AGENTS.md`.
## Future Enhancements
- [ ] Database persistence (SQLite/PostgreSQL)
- [ ] Alerting system (email/Slack notifications)
- [ ] Historical data / graphing
- [ ] Agent grouping and tagging
- [ ] Custom metric collection
- [ ] TLS/HTTPS support
- [ ] Multi-tenancy
- [ ] API documentation (Swagger)
## License
MIT License - See LICENSE file for details
## Support
- Check `QUICKSTART.md` for common setup issues
- Review `AGENTS.md` for development guidelines
- Open an issue for bugs or feature requests
---
**Made with ❤️ for system administrators and DevOps teams**

164
cmd/agent/main.go Normal file
View File

@@ -0,0 +1,164 @@
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"syscall"
"time"
"nerd-monitor/internal/stats"
)
func main() {
var (
server = flag.String("server", "", "server URL (required)")
interval = flag.Duration("interval", 15*time.Second, "reporting interval")
agentID = flag.String("id", "", "agent ID (auto-generated if empty)")
)
flag.Parse()
if *server == "" {
log.Fatal("--server flag is required")
}
// Normalize server URL (add http:// if missing)
*server = normalizeURL(*server)
// Generate or use provided agent ID
id := *agentID
if id == "" {
var err error
id, err = generateAgentID()
if err != nil {
log.Fatalf("Failed to generate agent ID: %v", err)
}
}
log.Printf("Starting agent with ID: %s\n", id)
log.Printf("Reporting to: %s every %v\n", *server, *interval)
// Initialize stats collector
collector := stats.NewCollector()
// Setup graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Report loop
ticker := time.NewTicker(*interval)
defer ticker.Stop()
// Report immediately on start
reportStats(id, *server, collector)
for {
select {
case <-ticker.C:
reportStats(id, *server, collector)
case <-sigChan:
log.Println("Agent shutting down gracefully...")
os.Exit(0)
}
}
}
// reportStats collects and reports system statistics to the server.
func reportStats(agentID, serverURL string, collector *stats.Collector) {
hostname, err := os.Hostname()
if err != nil {
hostname = agentID
}
stat, err := collector.Collect(hostname)
if err != nil {
log.Printf("Error collecting stats: %v", err)
return
}
// Marshal to JSON
body, err := json.Marshal(stat)
if err != nil {
log.Printf("Error marshaling stats: %v", err)
return
}
// Send to server
reportURL := fmt.Sprintf("%s/api/report?id=%s", serverURL, agentID)
resp, err := http.Post(reportURL, "application/json", bytes.NewReader(body))
if err != nil {
log.Printf("Error reporting stats: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
log.Printf("Server returned status %d", resp.StatusCode)
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),
)
}
// generateAgentID creates an ID based on hostname.
func generateAgentID() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
return hostname, nil
}
// normalizeURL ensures the URL has a valid scheme and port.
func normalizeURL(server string) string {
// If no scheme, add http://
if !strings.Contains(server, "://") {
server = "http://" + server
}
// Parse to check if port is specified
u, err := url.Parse(server)
if err != nil {
return server
}
// If no port specified, add default port 8080
if u.Port() == "" {
return u.Scheme + "://" + u.Hostname() + ":8080"
}
return server
}
// 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)
}
}

75
cmd/server/main.go Normal file
View File

@@ -0,0 +1,75 @@
package main
import (
"flag"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"github.com/go-chi/chi/v5"
"nerd-monitor/internal/api"
"nerd-monitor/internal/auth"
"nerd-monitor/internal/store"
"nerd-monitor/internal/ui"
)
func main() {
var (
addr = flag.String("addr", "0.0.0.0", "server address")
port = flag.String("port", "8080", "server port")
username = flag.String("username", "admin", "admin username")
password = flag.String("password", "admin", "admin password")
)
flag.Parse()
// Initialize dependencies
s := store.New()
authMgr := auth.New(*username, *password)
apiHandler := api.New(s)
uiHandler := ui.New(s, authMgr)
// Setup router
r := chi.NewRouter()
// Public routes (no auth required)
r.Post("/api/report", apiHandler.ReportStats)
r.Get("/api/agents", apiHandler.ListAgents)
r.Get("/api/agents/{id}", apiHandler.GetAgent)
r.Get("/login", uiHandler.Login)
r.Post("/login", uiHandler.Login)
// Protected routes (auth required)
r.Group(func(protectedRoutes chi.Router) {
protectedRoutes.Use(authMgr.Middleware)
protectedRoutes.Get("/", uiHandler.Dashboard)
protectedRoutes.Get("/agents/{id}", uiHandler.AgentDetail)
protectedRoutes.Post("/logout", uiHandler.Logout)
protectedRoutes.Post("/api/agents/remove-stale", uiHandler.RemoveStaleAgents)
protectedRoutes.Post("/api/agents/{id}/delete", uiHandler.DeleteAgent)
})
// Setup server
server := &http.Server{
Addr: *addr + ":" + *port,
Handler: r,
}
// Graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
log.Println("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)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}

22
go.mod Normal file
View File

@@ -0,0 +1,22 @@
module nerd-monitor
go 1.23.0
toolchain go1.24.4
require (
github.com/a-h/templ v0.3.960
github.com/go-chi/chi/v5 v5.0.11
github.com/shirou/gopsutil/v3 v3.24.1
)
require (
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/yusufpapurcu/wmi v1.2.3 // indirect
golang.org/x/sys v0.34.0 // indirect
)

51
go.sum Normal file
View File

@@ -0,0 +1,51 @@
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA=
github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/shirou/gopsutil/v3 v3.24.1 h1:R3t6ondCEvmARp3wxODhXMTLC/klMa87h2PHUw5m7QI=
github.com/shirou/gopsutil/v3 v3.24.1/go.mod h1:UU7a2MSBQa+kW1uuDq8DeEBS8kmrnQwsv2b5O513rwU=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

75
internal/api/api.go Normal file
View File

@@ -0,0 +1,75 @@
package api
import (
"encoding/json"
"nerd-monitor/internal/stats"
"nerd-monitor/internal/store"
"net/http"
)
// Handler manages HTTP requests.
type Handler struct {
store *store.Store
}
// New creates a new API handler.
func New(s *store.Store) *Handler {
return &Handler{store: s}
}
// ReportStats handles agent stats reports.
func (h *Handler) ReportStats(w http.ResponseWriter, r *http.Request) {
agentID := r.URL.Query().Get("id")
if agentID == "" {
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
var stat stats.Stats
if err := json.NewDecoder(r.Body).Decode(&stat); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
h.store.UpdateAgent(agentID, &stat)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// GetAgent returns stats for a single agent.
func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) {
agentID := r.URL.Query().Get("id")
if agentID == "" {
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
agent := h.store.GetAgent(agentID)
if agent == nil {
http.Error(w, "agent not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agent)
}
// ListAgents returns all agents.
func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) {
agents := h.store.GetAllAgents()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agents)
}
// DeleteAgent removes an agent.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
agentID := r.URL.Query().Get("id")
if agentID == "" {
http.Error(w, "missing agent id", http.StatusBadRequest)
return
}
h.store.DeleteAgent(agentID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}

6
internal/auth/errors.go Normal file
View File

@@ -0,0 +1,6 @@
package auth
import "errors"
// ErrInvalidCredentials is returned when login credentials are invalid.
var ErrInvalidCredentials = errors.New("invalid credentials")

View File

@@ -0,0 +1,98 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"net/http"
"sync"
"time"
)
// Session represents an authenticated user session.
type Session struct {
Token string
ExpiresAt time.Time
}
// Manager handles authentication and session management.
type Manager struct {
mu sync.RWMutex
sessions map[string]*Session
username string
password string
expiryDur time.Duration
}
// New creates a new authentication manager with default credentials.
func New(username, password string) *Manager {
return &Manager{
sessions: make(map[string]*Session),
username: username,
password: password,
expiryDur: 24 * time.Hour,
}
}
// Login validates credentials and creates a session.
func (m *Manager) Login(username, password string) (string, error) {
if username != m.username || password != m.password {
return "", ErrInvalidCredentials
}
token, err := generateToken()
if err != nil {
return "", err
}
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[token] = &Session{
Token: token,
ExpiresAt: time.Now().Add(m.expiryDur),
}
return token, nil
}
// Validate checks if a session token is valid.
func (m *Manager) Validate(token string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[token]
if !ok {
return false
}
return session.ExpiresAt.After(time.Now())
}
// Logout invalidates a session.
func (m *Manager) Logout(token string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, token)
}
// 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) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
// generateToken creates a random hex token.
func generateToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

67
internal/stats/stats.go Normal file
View File

@@ -0,0 +1,67 @@
package stats
import (
"runtime"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/disk"
"github.com/shirou/gopsutil/v3/mem"
)
// Stats represents system statistics.
type Stats struct {
CPUUsage float64 `json:"cpu_usage"`
RAMUsage uint64 `json:"ram_usage"`
RAMTotal uint64 `json:"ram_total"`
DiskUsage uint64 `json:"disk_usage"`
DiskTotal uint64 `json:"disk_total"`
Timestamp time.Time `json:"timestamp"`
Hostname string `json:"hostname"`
}
// Collector gathers system statistics.
type Collector struct{}
// NewCollector creates a new stats collector.
func NewCollector() *Collector {
return &Collector{}
}
// Collect gathers current system statistics.
func (c *Collector) Collect(hostname string) (*Stats, error) {
cpuPercent, err := cpu.Percent(time.Second, false)
if err != nil {
return nil, err
}
memStats, err := mem.VirtualMemory()
if err != nil {
return nil, err
}
diskStats, err := disk.Usage("/")
if err != nil {
return nil, err
}
var cpuUsage float64
if len(cpuPercent) > 0 {
cpuUsage = cpuPercent[0]
}
return &Stats{
CPUUsage: cpuUsage,
RAMUsage: memStats.Used,
RAMTotal: memStats.Total,
DiskUsage: diskStats.Used,
DiskTotal: diskStats.Total,
Timestamp: time.Now(),
Hostname: hostname,
}, nil
}
// GetNumCores returns the number of CPU cores.
func GetNumCores() int {
return runtime.NumCPU()
}

78
internal/store/store.go Normal file
View File

@@ -0,0 +1,78 @@
package store
import (
"sync"
"time"
"nerd-monitor/internal/stats"
)
// AgentStats represents the latest stats for an agent.
type AgentStats struct {
ID string
Hostname string
CPUUsage float64
RAMUsage uint64
RAMTotal uint64
DiskUsage uint64
DiskTotal uint64
LastSeen time.Time
}
// Store manages agent statistics in memory.
type Store struct {
mu sync.RWMutex
agents map[string]*AgentStats
}
// New creates a new store.
func New() *Store {
return &Store{
agents: make(map[string]*AgentStats),
}
}
// UpdateAgent updates or creates agent stats.
func (s *Store) UpdateAgent(id string, stat *stats.Stats) {
s.mu.Lock()
defer s.mu.Unlock()
s.agents[id] = &AgentStats{
ID: id,
Hostname: stat.Hostname,
CPUUsage: stat.CPUUsage,
RAMUsage: stat.RAMUsage,
RAMTotal: stat.RAMTotal,
DiskUsage: stat.DiskUsage,
DiskTotal: stat.DiskTotal,
LastSeen: time.Now(),
}
}
// GetAgent retrieves agent stats by ID.
func (s *Store) GetAgent(id string) *AgentStats {
s.mu.RLock()
defer s.mu.RUnlock()
return s.agents[id]
}
// GetAllAgents returns all agents.
func (s *Store) GetAllAgents() []*AgentStats {
s.mu.RLock()
defer s.mu.RUnlock()
agents := make([]*AgentStats, 0, len(s.agents))
for _, agent := range s.agents {
agents = append(agents, agent)
}
return agents
}
// DeleteAgent removes an agent.
func (s *Store) DeleteAgent(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.agents, id)
}

149
internal/ui/handlers.go Normal file
View File

@@ -0,0 +1,149 @@
package ui
import (
"context"
"net/http"
"time"
"nerd-monitor/internal/auth"
"nerd-monitor/internal/store"
"nerd-monitor/views"
)
// Handler serves UI pages.
type Handler struct {
store *store.Store
auth *auth.Manager
}
// New creates a new UI handler.
func New(s *store.Store, a *auth.Manager) *Handler {
return &Handler{
store: s,
auth: a,
}
}
// Dashboard renders the dashboard page.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
agents := h.store.GetAllAgents()
staleAgents := h.getStaleAgents()
component := views.Dashboard(agents, staleAgents)
component.Render(context.Background(), w)
}
// AgentDetail renders the agent detail page.
func (h *Handler) AgentDetail(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
agent := h.store.GetAgent(agentID)
if agent == nil {
http.NotFound(w, r)
return
}
component := views.AgentDetail(agent)
component.Render(context.Background(), w)
}
// Login renders the login page.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
component := views.LoginPage("")
component.Render(context.Background(), w)
return
}
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
token, err := h.auth.Login(username, password)
if err != nil {
component := views.LoginPage("Invalid credentials")
component.Render(context.Background(), w)
return
}
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
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Logout handles logout.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cookie, err := r.Cookie("session_token")
if err == nil {
h.auth.Logout(cookie.Value)
}
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) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentIDs := r.Form["agent_ids"]
for _, id := range agentIDs {
h.store.DeleteAgent(id)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// DeleteAgent handles single agent deletion.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentID := r.PathValue("id")
h.store.DeleteAgent(agentID)
http.Redirect(w, r, "/", 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
}

67
views/agent_detail.templ Normal file
View File

@@ -0,0 +1,67 @@
package views
import (
"fmt"
"nerd-monitor/internal/store"
)
templ AgentDetail(agent *store.AgentStats) {
@BaseLayout(agent.Hostname, agentDetailContent(agent))
}
templ agentDetailContent(agent *store.AgentStats) {
<a href="/" class="back-link">← Back to Dashboard</a>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<h2 style="margin: 0;">{ agent.Hostname }</h2>
@AgentStatusBadge(agent.LastSeen)
</div>
<div class="grid" style="grid-template-columns: 1fr;">
<div class="card">
<div class="card-title">CPU Usage</div>
<div class="metric-value" style="font-size: 2rem; margin: 1rem 0;">
{ FormatPercent(agent.CPUUsage) }
</div>
<div class="progress-bar">
<div class={ "progress-fill", calcProgressClass(agent.CPUUsage/100) } style={ fmt.Sprintf("width: %.1f%%", agent.CPUUsage) }></div>
</div>
<div style="margin-top: 1rem; font-size: 0.875rem; color: #94a3b8;">
Last updated: { FormatTime(agent.LastSeen) }
</div>
</div>
</div>
<div class="grid" style="margin-top: 1.5rem;">
<div class="card">
<div class="card-title">Memory Usage</div>
@UsageBar("RAM", agent.RAMUsage, agent.RAMTotal)
</div>
<div class="card">
<div class="card-title">Disk Usage</div>
@UsageBar("Disk", agent.DiskUsage, agent.DiskTotal)
</div>
</div>
<div class="card" style="margin-top: 1.5rem;">
<div class="card-title">Agent Information</div>
<div class="metric-row">
<span class="metric-label">Agent ID</span>
<span class="metric-value" style="font-family: monospace; font-size: 0.875rem;">{ agent.ID }</span>
</div>
<div class="metric-row">
<span class="metric-label">Hostname</span>
<span class="metric-value">{ agent.Hostname }</span>
</div>
<div class="metric-row">
<span class="metric-label">Last Seen</span>
<span class="metric-value">{ agent.LastSeen.Format("2006-01-02 15:04:05") }</span>
</div>
</div>
<div class="btn-group" style="margin-top: 1.5rem;">
<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?');">
<button type="submit" class="btn btn-danger">Delete Agent</button>
</form>
</div>
}

138
views/components.templ Normal file
View File

@@ -0,0 +1,138 @@
package views
import "fmt"
import "time"
// FormatBytes converts bytes to human-readable format
func FormatBytes(bytes uint64) string {
const (
kb = 1024
mb = kb * 1024
gb = mb * 1024
tb = gb * 1024
)
switch {
case bytes >= tb:
return fmt.Sprintf("%.2f TB", float64(bytes)/float64(tb))
case bytes >= gb:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(gb))
case bytes >= mb:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(mb))
case bytes >= kb:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(kb))
default:
return fmt.Sprintf("%d B", bytes)
}
}
// FormatPercent formats percentage with 1 decimal place
func FormatPercent(percent float64) string {
return fmt.Sprintf("%.1f%%", percent)
}
// FormatTime formats time as relative or absolute
func FormatTime(t time.Time) string {
if t.IsZero() {
return "Never"
}
duration := time.Since(t)
switch {
case duration < time.Minute:
return "Just now"
case duration < time.Hour:
return fmt.Sprintf("%d minutes ago", int(duration.Minutes()))
case duration < 24*time.Hour:
return fmt.Sprintf("%d hours ago", int(duration.Hours()))
case duration < 30*24*time.Hour:
return fmt.Sprintf("%d days ago", int(duration.Hours()/24))
default:
return fmt.Sprintf("%d months ago", int(duration.Hours()/24/30))
}
}
// StatusColor returns CSS class for status based on conditions
func StatusColor(cpuUsage, ramPercent, lastSeenAge time.Duration) string {
if lastSeenAge > 24*time.Hour {
return "status-red"
}
if cpuUsage > 80 || ramPercent > 80 {
return "status-yellow"
}
return "status-green"
}
// IsAgentOnline checks if agent is currently online (reported within 15 seconds)
func IsAgentOnline(lastSeen time.Time) bool {
return time.Since(lastSeen) < 15*time.Second
}
templ StatusCard(label string, value string, status string) {
<div class="card">
<div class="card-title">
{ label }
<span class={ "status-badge", status }>{ status }</span>
</div>
<div class="metric-value" style="font-size: 1.5rem; margin-top: 0.5rem;">
{ value }
</div>
</div>
}
templ UsageBar(label string, used uint64, total uint64) {
<div class="metric-row">
<span class="metric-label">{ label }</span>
<span class="metric-value">{ FormatBytes(used) } / { FormatBytes(total) }</span>
</div>
<div class="progress-bar">
if total > 0 {
<div class={ "progress-fill", calcProgressClass(float64(used)/float64(total)) } style={ fmt.Sprintf("width: %.1f%%", float64(used)/float64(total)*100) }></div>
}
</div>
}
// calcProgressClass returns the CSS class based on percentage
func calcProgressClass(percent float64) string {
if percent > 0.8 {
return "progress-high"
}
if percent > 0.6 {
return "progress-medium"
}
return ""
}
templ AgentRow(id string, hostname string, cpuUsage float64, ramUsage uint64, ramTotal uint64, diskUsage uint64, diskTotal uint64, lastSeen time.Time) {
<tr>
<td>
<div style="display: flex; align-items: center; gap: 0.5rem;">
@AgentStatusBadge(lastSeen)
<a href={ templ.SafeURL(fmt.Sprintf("/agents/%s", id)) }>{ hostname }</a>
</div>
</td>
<td>{ FormatPercent(cpuUsage) }</td>
<td>{ FormatBytes(ramUsage) } / { FormatBytes(ramTotal) }</td>
<td>{ FormatBytes(diskUsage) } / { FormatBytes(diskTotal) }</td>
<td class="timestamp">{ FormatTime(lastSeen) }</td>
</tr>
}
templ AgentStatusBadge(lastSeen time.Time) {
if IsAgentOnline(lastSeen) {
<span class="status-badge status-green" style="margin: 0;">Online</span>
} else {
<span class="status-badge status-red" style="margin: 0;">Offline</span>
}
}
templ StaleAgentAlert(count int, agents []interface{}) {
if count > 0 {
<div class="alert alert-warning">
<strong>⚠️ { count } agent(s) haven't reported for 6+ months</strong>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">
These agents may be offline. You can remove them to keep your dashboard clean.
</p>
</div>
}
}

81
views/dashboard.templ Normal file
View File

@@ -0,0 +1,81 @@
package views
import (
"nerd-monitor/internal/store"
)
templ Dashboard(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
@BaseLayout("Dashboard", dashboardContent(agents, staleAgents))
}
templ dashboardContent(agents []*store.AgentStats, staleAgents []*store.AgentStats) {
<h2 style="margin-bottom: 1rem;">Agent Status Overview</h2>
@StaleAgentAlert(len(staleAgents), nil)
if len(agents) == 0 {
<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>
} 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>
</form>
</div>
<script>
function toggleAll(checkbox) {
const checkboxes = document.querySelectorAll('.agent-checkbox');
checkboxes.forEach(cb => cb.checked = checkbox.checked);
}
</script>
}
}

3
views/generate.go Normal file
View File

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

297
views/layout.templ Normal file
View File

@@ -0,0 +1,297 @@
package views
templ BaseLayout(title string, content templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="refresh" content="5" />
<title>{ title } - Nerd Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background-color: #0f172a;
color: #e2e8f0;
line-height: 1.5;
min-height: 100vh;
}
header {
background-color: #1e293b;
border-bottom: 1px solid #334155;
padding: 1rem 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.header-container {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
}
.nav-links {
display: flex;
gap: 1rem;
align-items: center;
}
a {
color: #3b82f6;
text-decoration: none;
transition: color 0.2s;
}
a:hover {
color: #60a5fa;
}
main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.container {
width: 100%;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: border-color 0.2s;
}
.card:hover {
border-color: #3b82f6;
}
.card-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.status-green {
background-color: #10b981;
color: #ffffff;
}
.status-yellow {
background-color: #f59e0b;
color: #ffffff;
}
.status-red {
background-color: #ef4444;
color: #ffffff;
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 0.75rem 0;
border-bottom: 1px solid #334155;
}
.metric-row:last-child {
border-bottom: none;
}
.metric-label {
color: #94a3b8;
font-size: 0.875rem;
}
.metric-value {
font-weight: 600;
color: #e2e8f0;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #334155;
border-radius: 4px;
overflow: hidden;
margin-top: 0.5rem;
}
.progress-fill {
height: 100%;
background-color: #3b82f6;
transition: width 0.3s;
}
.progress-high {
background-color: #ef4444;
}
.progress-medium {
background-color: #f59e0b;
}
.btn {
display: inline-block;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: #ffffff;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.2s;
text-decoration: none;
}
.btn:hover {
background-color: #2563eb;
}
.btn-danger {
background-color: #ef4444;
}
.btn-danger:hover {
background-color: #dc2626;
}
.btn-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.alert {
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
border-left: 4px solid;
}
.alert-warning {
background-color: #7c2d12;
border-color: #f59e0b;
color: #fed7aa;
}
.alert-info {
background-color: #0c4a6e;
border-color: #3b82f6;
color: #bfdbfe;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
th {
background-color: #334155;
padding: 1rem;
text-align: left;
font-weight: 600;
border-bottom: 1px solid #475569;
font-size: 0.875rem;
color: #cbd5e1;
}
td {
padding: 1rem;
border-bottom: 1px solid #334155;
}
tr:hover {
background-color: #1e293b;
}
.timestamp {
color: #94a3b8;
font-size: 0.875rem;
}
.logout-btn {
background-color: #64748b;
color: #ffffff;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background-color 0.2s;
text-decoration: none;
}
.logout-btn:hover {
background-color: #475569;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #3b82f6;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<header>
<div class="header-container">
<h1>Nerd Monitor</h1>
<div class="nav-links">
<a href="/">Dashboard</a>
<form method="POST" action="/logout" style="margin: 0;">
<button type="submit" class="logout-btn">Logout</button>
</form>
</div>
</div>
</header>
<main>
<div class="container">
@content
</div>
</main>
</body>
</html>
}

153
views/login.templ Normal file
View File

@@ -0,0 +1,153 @@
package views
templ LoginPage(errorMsg string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - Nerd Monitor</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
width: 100%;
max-width: 400px;
padding: 2rem;
}
.login-card {
background-color: #1e293b;
border: 1px solid #334155;
border-radius: 0.5rem;
padding: 2rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
}
h1 {
font-size: 1.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-align: center;
}
.subtitle {
text-align: center;
color: #94a3b8;
margin-bottom: 2rem;
font-size: 0.875rem;
}
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #e2e8f0;
font-size: 0.875rem;
}
input {
width: 100%;
padding: 0.75rem;
background-color: #0f172a;
border: 1px solid #334155;
border-radius: 0.375rem;
color: #e2e8f0;
font-size: 1rem;
transition: border-color 0.2s;
}
input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.btn-login {
width: 100%;
padding: 0.75rem;
background-color: #3b82f6;
color: #ffffff;
border: none;
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-login:hover {
background-color: #2563eb;
}
.error {
background-color: #7c2d12;
border: 1px solid #dc2626;
color: #fed7aa;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.info {
background-color: #0c4a6e;
border: 1px solid #3b82f6;
color: #bfdbfe;
padding: 1rem;
border-radius: 0.375rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<h1>Nerd Monitor</h1>
<p class="subtitle">System Monitoring Dashboard</p>
if errorMsg != "" {
<div class="error">{ errorMsg }</div>
}
<div class="info">
<strong>Demo Credentials:</strong><br/>
Username: <code>admin</code><br/>
Password: <code>admin</code>
</div>
<form method="POST" action="/login">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" required autofocus />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
<button type="submit" class="btn-login">Sign In</button>
</form>
</div>
</div>
</body>
</html>
}