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
165 lines
3.5 KiB
Go
165 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|