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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

@@ -0,0 +1,149 @@
package ui
import (
"context"
"net/http"
"time"
"nerd-monitor/internal/auth"
"nerd-monitor/internal/store"
"nerd-monitor/views"
)
// Handler serves UI pages.
type Handler struct {
store *store.Store
auth *auth.Manager
}
// New creates a new UI handler.
func New(s *store.Store, a *auth.Manager) *Handler {
return &Handler{
store: s,
auth: a,
}
}
// Dashboard renders the dashboard page.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
agents := h.store.GetAllAgents()
staleAgents := h.getStaleAgents()
component := views.Dashboard(agents, staleAgents)
component.Render(context.Background(), w)
}
// AgentDetail renders the agent detail page.
func (h *Handler) AgentDetail(w http.ResponseWriter, r *http.Request) {
agentID := r.PathValue("id")
agent := h.store.GetAgent(agentID)
if agent == nil {
http.NotFound(w, r)
return
}
component := views.AgentDetail(agent)
component.Render(context.Background(), w)
}
// Login renders the login page.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
component := views.LoginPage("")
component.Render(context.Background(), w)
return
}
if r.Method == http.MethodPost {
username := r.FormValue("username")
password := r.FormValue("password")
token, err := h.auth.Login(username, password)
if err != nil {
component := views.LoginPage("Invalid credentials")
component.Render(context.Background(), w)
return
}
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: token,
Path: "/",
MaxAge: 86400,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Logout handles logout.
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
cookie, err := r.Cookie("session_token")
if err == nil {
h.auth.Logout(cookie.Value)
}
http.SetCookie(w, &http.Cookie{
Name: "session_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// RemoveStaleAgents handles bulk removal of stale agents.
func (h *Handler) RemoveStaleAgents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentIDs := r.Form["agent_ids"]
for _, id := range agentIDs {
h.store.DeleteAgent(id)
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// DeleteAgent handles single agent deletion.
func (h *Handler) DeleteAgent(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
agentID := r.PathValue("id")
h.store.DeleteAgent(agentID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// getStaleAgents returns agents that haven't reported in 6 months.
func (h *Handler) getStaleAgents() []*store.AgentStats {
const staleThreshold = 6 * 30 * 24 * time.Hour
allAgents := h.store.GetAllAgents()
var stale []*store.AgentStats
for _, agent := range allAgents {
if time.Since(agent.LastSeen) > staleThreshold {
stale = append(stale, agent)
}
}
return stale
}