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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user