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:
33
.gitignore
vendored
Normal file
33
.gitignore
vendored
Normal 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
42
AGENTS.md
Normal 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
55
Makefile
Normal 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
136
QUICKSTART.md
Normal 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
354
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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
164
cmd/agent/main.go
Normal 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
75
cmd/server/main.go
Normal 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
22
go.mod
Normal 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
51
go.sum
Normal 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
75
internal/api/api.go
Normal 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
6
internal/auth/errors.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrInvalidCredentials is returned when login credentials are invalid.
|
||||
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
98
internal/auth/middleware.go
Normal file
98
internal/auth/middleware.go
Normal 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
67
internal/stats/stats.go
Normal 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
78
internal/store/store.go
Normal 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
149
internal/ui/handlers.go
Normal 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
67
views/agent_detail.templ
Normal 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
138
views/components.templ
Normal 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
81
views/dashboard.templ
Normal 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
3
views/generate.go
Normal file
@@ -0,0 +1,3 @@
|
||||
//go:generate templ generate
|
||||
|
||||
package views
|
||||
297
views/layout.templ
Normal file
297
views/layout.templ
Normal 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
153
views/login.templ
Normal 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>
|
||||
}
|
||||
Reference in New Issue
Block a user