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