Files
tyto/backend/internal/api/routes.go
vikingowl f4dbc55851 feat: add dashboard customization, alerts, PWA, and mobile support
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>
2025-12-28 05:35:28 +01:00

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"})
}
}