Dashboard Editor & Layout: - Full-screen visual editor for reorganizing cards - Drag-and-drop cards between sections - Toggle card visibility with persistence to localStorage - Reset to default layout option Alerts System: - Threshold-based alerts for CPU, memory, temperature, disk, GPU - Alert manager with duration requirements - AlertsCard component with settings UI - API endpoints for alerts CRUD New Collectors: - Docker container monitoring with parallel stats fetching - Systemd service status via D-Bus - Historical metrics storage (1 hour at 1s intervals) PWA Support: - Service worker with offline caching - Web app manifest with SVG icons - iOS PWA meta tags Mobile Responsive: - Collapsible hamburger menu on mobile - Adaptive grid layouts for all screen sizes - Touch-friendly hover states - Safe area insets for notched devices UI Enhancements: - Light/dark theme toggle with persistence - Keyboard shortcuts (T=theme, R=refresh, ?=help) - Per-process expandable details in ProcessesCard - Sparkline charts for historical data Performance Fixes: - Buffered SSE channels to prevent blocking - Parallel Docker stats collection with timeout - D-Bus timeout for systemd collector Tests: - Unit tests for CPU, memory, network collectors - Alert manager tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
361 lines
11 KiB
Go
361 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-contrib/cors"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"system-monitor/internal/config"
|
|
"system-monitor/internal/models"
|
|
"system-monitor/internal/sse"
|
|
)
|
|
|
|
type Server struct {
|
|
router *gin.Engine
|
|
broker *sse.Broker
|
|
cfg *config.Config
|
|
rateLimiter *RateLimiter
|
|
}
|
|
|
|
// RateLimiter implements a simple token bucket rate limiter
|
|
type RateLimiter struct {
|
|
requests map[string][]time.Time
|
|
mu sync.Mutex
|
|
limit int
|
|
window time.Duration
|
|
}
|
|
|
|
func NewRateLimiter(limit int, window time.Duration) *RateLimiter {
|
|
return &RateLimiter{
|
|
requests: make(map[string][]time.Time),
|
|
limit: limit,
|
|
window: window,
|
|
}
|
|
}
|
|
|
|
func (r *RateLimiter) Allow(ip string) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
now := time.Now()
|
|
cutoff := now.Add(-r.window)
|
|
|
|
// Clean old requests
|
|
var recent []time.Time
|
|
for _, t := range r.requests[ip] {
|
|
if t.After(cutoff) {
|
|
recent = append(recent, t)
|
|
}
|
|
}
|
|
|
|
if len(recent) >= r.limit {
|
|
r.requests[ip] = recent
|
|
return false
|
|
}
|
|
|
|
r.requests[ip] = append(recent, now)
|
|
return true
|
|
}
|
|
|
|
func NewServer(cfg *config.Config, broker *sse.Broker) *Server {
|
|
gin.SetMode(gin.ReleaseMode)
|
|
router := gin.New()
|
|
router.Use(gin.Recovery())
|
|
|
|
// CORS configuration
|
|
router.Use(cors.New(cors.Config{
|
|
AllowOrigins: []string{"*"},
|
|
AllowMethods: []string{"GET", "POST", "OPTIONS"},
|
|
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
|
ExposeHeaders: []string{"Content-Length"},
|
|
AllowCredentials: false,
|
|
MaxAge: 12 * time.Hour,
|
|
}))
|
|
|
|
s := &Server{
|
|
router: router,
|
|
broker: broker,
|
|
cfg: cfg,
|
|
rateLimiter: NewRateLimiter(100, time.Minute), // 100 requests per minute
|
|
}
|
|
|
|
s.setupRoutes()
|
|
return s
|
|
}
|
|
|
|
func (s *Server) setupRoutes() {
|
|
// Health check (no auth required)
|
|
s.router.GET("/health", s.healthHandler)
|
|
|
|
// API v1
|
|
v1 := s.router.Group("/api/v1")
|
|
|
|
// Apply rate limiting
|
|
v1.Use(s.rateLimitMiddleware())
|
|
|
|
// Apply basic auth if configured
|
|
if s.cfg.AuthEnabled {
|
|
v1.Use(s.basicAuthMiddleware())
|
|
}
|
|
|
|
{
|
|
v1.GET("/metrics", s.metricsHandler)
|
|
v1.GET("/stream", s.streamHandler)
|
|
v1.GET("/history", s.historyHandler)
|
|
v1.POST("/settings/refresh", s.setRefreshHandler)
|
|
v1.GET("/settings/refresh", s.getRefreshHandler)
|
|
|
|
// Alerts endpoints
|
|
v1.GET("/alerts", s.getAlertsHandler)
|
|
v1.GET("/alerts/config", s.getAlertConfigHandler)
|
|
v1.POST("/alerts/config", s.setAlertConfigHandler)
|
|
v1.POST("/alerts/:id/acknowledge", s.acknowledgeAlertHandler)
|
|
}
|
|
|
|
// Prometheus metrics endpoint (no auth, rate limited)
|
|
s.router.GET("/metrics", s.rateLimitMiddleware(), s.prometheusHandler)
|
|
}
|
|
|
|
func (s *Server) rateLimitMiddleware() gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
ip := c.ClientIP()
|
|
if !s.rateLimiter.Allow(ip) {
|
|
c.JSON(http.StatusTooManyRequests, gin.H{"error": "rate limit exceeded"})
|
|
c.Abort()
|
|
return
|
|
}
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
func (s *Server) basicAuthMiddleware() gin.HandlerFunc {
|
|
return gin.BasicAuth(gin.Accounts{
|
|
s.cfg.AuthUser: s.cfg.AuthPass,
|
|
})
|
|
}
|
|
|
|
func (s *Server) Run() error {
|
|
return s.router.Run(":" + s.cfg.Port)
|
|
}
|
|
|
|
func (s *Server) RunTLS(certFile, keyFile string) error {
|
|
return s.router.RunTLS(":"+s.cfg.Port, certFile, keyFile)
|
|
}
|
|
|
|
func (s *Server) healthHandler(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) metricsHandler(c *gin.Context) {
|
|
metrics := s.broker.CollectAll()
|
|
c.JSON(http.StatusOK, metrics)
|
|
}
|
|
|
|
func (s *Server) historyHandler(c *gin.Context) {
|
|
history := s.broker.History.GetAll()
|
|
c.JSON(http.StatusOK, history)
|
|
}
|
|
|
|
func (s *Server) streamHandler(c *gin.Context) {
|
|
// Set SSE headers
|
|
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
|
c.Writer.Header().Set("Cache-Control", "no-cache")
|
|
c.Writer.Header().Set("Connection", "keep-alive")
|
|
c.Writer.Header().Set("X-Accel-Buffering", "no")
|
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
// Create client channel
|
|
clientChan := make(chan []byte, 10)
|
|
s.broker.Register(clientChan)
|
|
|
|
// Clean up on disconnect
|
|
defer s.broker.Unregister(clientChan)
|
|
|
|
// Send initial data immediately
|
|
initial := s.broker.CollectAll()
|
|
initialJSON, err := json.Marshal(initial)
|
|
if err == nil {
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", initialJSON)
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
// Stream data using Server-Sent Events
|
|
notify := c.Request.Context().Done()
|
|
for {
|
|
select {
|
|
case <-notify:
|
|
return
|
|
case data := <-clientChan:
|
|
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
|
|
c.Writer.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
type RefreshRequest struct {
|
|
Interval int `json:"interval"` // seconds
|
|
}
|
|
|
|
func (s *Server) setRefreshHandler(c *gin.Context) {
|
|
var req RefreshRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
|
|
return
|
|
}
|
|
|
|
if req.Interval < 1 || req.Interval > 60 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "interval must be between 1 and 60 seconds"})
|
|
return
|
|
}
|
|
|
|
s.broker.SetInterval(time.Duration(req.Interval) * time.Second)
|
|
c.JSON(http.StatusOK, gin.H{"interval": req.Interval})
|
|
}
|
|
|
|
func (s *Server) getRefreshHandler(c *gin.Context) {
|
|
interval := s.broker.GetInterval()
|
|
c.JSON(http.StatusOK, gin.H{"interval": int(interval.Seconds())})
|
|
}
|
|
|
|
func (s *Server) prometheusHandler(c *gin.Context) {
|
|
metrics := s.broker.CollectAll()
|
|
|
|
var sb strings.Builder
|
|
|
|
// CPU metrics
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_cpu_usage_percent CPU usage percentage\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_cpu_usage_percent gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_cpu_usage_percent{type=\"total\"} %.2f\n", metrics.CPU.TotalUsage))
|
|
for _, core := range metrics.CPU.Cores {
|
|
sb.WriteString(fmt.Sprintf("sysmon_cpu_usage_percent{type=\"core\",core=\"%d\"} %.2f\n", core.ID, core.Usage))
|
|
}
|
|
|
|
// Memory metrics
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_memory_bytes Memory in bytes\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_memory_bytes gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_memory_bytes{type=\"total\"} %d\n", metrics.Memory.Total))
|
|
sb.WriteString(fmt.Sprintf("sysmon_memory_bytes{type=\"used\"} %d\n", metrics.Memory.Used))
|
|
sb.WriteString(fmt.Sprintf("sysmon_memory_bytes{type=\"available\"} %d\n", metrics.Memory.Available))
|
|
sb.WriteString(fmt.Sprintf("sysmon_memory_bytes{type=\"cached\"} %d\n", metrics.Memory.Cached))
|
|
|
|
// GPU metrics
|
|
if metrics.GPU.Available {
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_gpu_usage_percent GPU usage percentage\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_gpu_usage_percent gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_gpu_usage_percent %d\n", metrics.GPU.Utilization))
|
|
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_gpu_memory_bytes GPU memory in bytes\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_gpu_memory_bytes gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_gpu_memory_bytes{type=\"used\"} %d\n", metrics.GPU.VRAMUsed))
|
|
sb.WriteString(fmt.Sprintf("sysmon_gpu_memory_bytes{type=\"total\"} %d\n", metrics.GPU.VRAMTotal))
|
|
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_gpu_temperature_celsius GPU temperature\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_gpu_temperature_celsius gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_gpu_temperature_celsius %.1f\n", metrics.GPU.Temperature))
|
|
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_gpu_power_watts GPU power consumption\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_gpu_power_watts gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_gpu_power_watts %.1f\n", metrics.GPU.PowerWatts))
|
|
}
|
|
|
|
// Temperature metrics
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_temperature_celsius Temperature sensor readings\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_temperature_celsius gauge\n"))
|
|
for _, sensor := range metrics.Temperature.Sensors {
|
|
label := sensor.Label
|
|
if label == "" {
|
|
label = "default"
|
|
}
|
|
sb.WriteString(fmt.Sprintf("sysmon_temperature_celsius{sensor=\"%s\",label=\"%s\"} %.1f\n",
|
|
sensor.Name, label, sensor.Temperature))
|
|
}
|
|
|
|
// Disk metrics
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_disk_bytes Disk space in bytes\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_disk_bytes gauge\n"))
|
|
for _, mount := range metrics.Disk.Mounts {
|
|
sb.WriteString(fmt.Sprintf("sysmon_disk_bytes{device=\"%s\",mount=\"%s\",type=\"total\"} %d\n",
|
|
mount.Device, mount.MountPoint, mount.Total))
|
|
sb.WriteString(fmt.Sprintf("sysmon_disk_bytes{device=\"%s\",mount=\"%s\",type=\"used\"} %d\n",
|
|
mount.Device, mount.MountPoint, mount.Used))
|
|
}
|
|
|
|
// Network metrics
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_network_bytes Network traffic in bytes\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_network_bytes counter\n"))
|
|
for _, iface := range metrics.Network.Interfaces {
|
|
sb.WriteString(fmt.Sprintf("sysmon_network_bytes{interface=\"%s\",direction=\"rx\"} %d\n",
|
|
iface.Name, iface.RxBytes))
|
|
sb.WriteString(fmt.Sprintf("sysmon_network_bytes{interface=\"%s\",direction=\"tx\"} %d\n",
|
|
iface.Name, iface.TxBytes))
|
|
}
|
|
|
|
// Process count
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_process_count Number of processes\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_process_count gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_process_count %d\n", metrics.Processes.Total))
|
|
|
|
// Docker metrics
|
|
if metrics.Docker.Available {
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_docker_containers Docker container counts\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_docker_containers gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_docker_containers{state=\"total\"} %d\n", metrics.Docker.Total))
|
|
sb.WriteString(fmt.Sprintf("sysmon_docker_containers{state=\"running\"} %d\n", metrics.Docker.Running))
|
|
}
|
|
|
|
// Systemd metrics
|
|
if metrics.Systemd.Available {
|
|
sb.WriteString(fmt.Sprintf("# HELP sysmon_systemd_services Systemd service counts\n"))
|
|
sb.WriteString(fmt.Sprintf("# TYPE sysmon_systemd_services gauge\n"))
|
|
sb.WriteString(fmt.Sprintf("sysmon_systemd_services{state=\"total\"} %d\n", metrics.Systemd.Total))
|
|
sb.WriteString(fmt.Sprintf("sysmon_systemd_services{state=\"active\"} %d\n", metrics.Systemd.Active))
|
|
sb.WriteString(fmt.Sprintf("sysmon_systemd_services{state=\"failed\"} %d\n", metrics.Systemd.Failed))
|
|
}
|
|
|
|
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(sb.String()))
|
|
}
|
|
|
|
func (s *Server) ListenAddr() string {
|
|
return fmt.Sprintf(":%s", s.cfg.Port)
|
|
}
|
|
|
|
// Alert handlers
|
|
func (s *Server) getAlertsHandler(c *gin.Context) {
|
|
response := models.AlertsResponse{
|
|
Active: s.broker.Alerts.GetActiveAlerts(),
|
|
History: s.broker.Alerts.GetAlertHistory(),
|
|
Config: s.broker.Alerts.GetConfig(),
|
|
}
|
|
c.JSON(http.StatusOK, response)
|
|
}
|
|
|
|
func (s *Server) getAlertConfigHandler(c *gin.Context) {
|
|
config := s.broker.Alerts.GetConfig()
|
|
c.JSON(http.StatusOK, config)
|
|
}
|
|
|
|
func (s *Server) setAlertConfigHandler(c *gin.Context) {
|
|
var config models.AlertConfig
|
|
if err := c.ShouldBindJSON(&config); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid config format"})
|
|
return
|
|
}
|
|
s.broker.Alerts.SetConfig(config)
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) acknowledgeAlertHandler(c *gin.Context) {
|
|
alertID := c.Param("id")
|
|
if s.broker.Alerts.AcknowledgeAlert(alertID) {
|
|
c.JSON(http.StatusOK, gin.H{"status": "acknowledged"})
|
|
} else {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "alert not found"})
|
|
}
|
|
}
|