diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d76b3cb..117784a 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -15,11 +15,28 @@ func main() { log.Printf("Reading from: proc=%s, sys=%s", cfg.ProcPath, cfg.SysPath) log.Printf("Default refresh interval: %s", cfg.RefreshInterval) + if cfg.AuthEnabled { + log.Printf("Basic authentication enabled for user: %s", cfg.AuthUser) + } + + if cfg.TLSEnabled { + log.Printf("TLS enabled with cert: %s", cfg.TLSCertFile) + } + broker := sse.NewBroker(cfg) go broker.Run() server := api.NewServer(cfg, broker) - if err := server.Run(); err != nil { + + var err error + if cfg.TLSEnabled { + log.Printf("Starting HTTPS server on port %s", cfg.Port) + err = server.RunTLS(cfg.TLSCertFile, cfg.TLSKeyFile) + } else { + err = server.Run() + } + + if err != nil { log.Fatalf("Failed to start server: %v", err) } } diff --git a/backend/go.mod b/backend/go.mod index bececfe..051bef8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,11 @@ require github.com/gin-gonic/gin v1.10.0 require github.com/gin-contrib/cors v1.7.2 +require ( + github.com/godbus/dbus/v5 v5.1.0 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect @@ -33,5 +38,4 @@ require ( golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/protobuf v1.34.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 71766ff..921a20e 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -28,6 +28,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/backend/internal/alerts/manager.go b/backend/internal/alerts/manager.go new file mode 100644 index 0000000..62262c8 --- /dev/null +++ b/backend/internal/alerts/manager.go @@ -0,0 +1,211 @@ +package alerts + +import ( + "fmt" + "sync" + "time" + + "system-monitor/internal/models" +) + +// Manager handles alert threshold checking and tracking +type Manager struct { + mu sync.RWMutex + config models.AlertConfig + activeAlerts map[string]*models.Alert + alertHistory []models.Alert + thresholdHits map[string]time.Time // Track when threshold was first exceeded + maxHistory int +} + +// NewManager creates a new alert manager with default config +func NewManager() *Manager { + return &Manager{ + config: models.DefaultAlertConfig(), + activeAlerts: make(map[string]*models.Alert), + alertHistory: make([]models.Alert, 0), + thresholdHits: make(map[string]time.Time), + maxHistory: 100, + } +} + +// GetConfig returns current alert configuration +func (m *Manager) GetConfig() models.AlertConfig { + m.mu.RLock() + defer m.mu.RUnlock() + return m.config +} + +// SetConfig updates alert configuration +func (m *Manager) SetConfig(config models.AlertConfig) { + m.mu.Lock() + defer m.mu.Unlock() + m.config = config +} + +// GetActiveAlerts returns all currently active alerts +func (m *Manager) GetActiveAlerts() []models.Alert { + m.mu.RLock() + defer m.mu.RUnlock() + + alerts := make([]models.Alert, 0, len(m.activeAlerts)) + for _, alert := range m.activeAlerts { + alerts = append(alerts, *alert) + } + return alerts +} + +// GetAlertHistory returns recent alert history +func (m *Manager) GetAlertHistory() []models.Alert { + m.mu.RLock() + defer m.mu.RUnlock() + + history := make([]models.Alert, len(m.alertHistory)) + copy(history, m.alertHistory) + return history +} + +// AcknowledgeAlert marks an alert as acknowledged +func (m *Manager) AcknowledgeAlert(alertID string) bool { + m.mu.Lock() + defer m.mu.Unlock() + + // Search by alert ID field, not map key + for _, alert := range m.activeAlerts { + if alert.ID == alertID { + alert.Acknowledged = true + return true + } + } + return false +} + +// CheckMetrics evaluates metrics against thresholds and triggers/resolves alerts +func (m *Manager) CheckMetrics(metrics models.AllMetrics) []models.Alert { + m.mu.Lock() + defer m.mu.Unlock() + + var newAlerts []models.Alert + now := time.Now() + + for _, threshold := range m.config.Thresholds { + if !threshold.Enabled { + continue + } + + var value float64 + var label string + + switch threshold.Type { + case models.AlertTypeCPU: + value = metrics.CPU.TotalUsage + label = "CPU Usage" + case models.AlertTypeMemory: + if metrics.Memory.Total > 0 { + value = float64(metrics.Memory.Used) / float64(metrics.Memory.Total) * 100 + } + label = "Memory Usage" + case models.AlertTypeTemperature: + // Find max temperature + for _, sensor := range metrics.Temperature.Sensors { + if sensor.Temperature > value { + value = sensor.Temperature + label = fmt.Sprintf("Temperature (%s)", sensor.Name) + } + } + if label == "" { + label = "Temperature" + } + case models.AlertTypeDisk: + // Find max disk usage + for _, mount := range metrics.Disk.Mounts { + if mount.UsedPercent > value { + value = mount.UsedPercent + label = fmt.Sprintf("Disk (%s)", mount.MountPoint) + } + } + if label == "" { + label = "Disk Usage" + } + case models.AlertTypeGPU: + if metrics.GPU.Available { + value = float64(metrics.GPU.Utilization) + label = "GPU Usage" + } + } + + // Check thresholds + alertKey := string(threshold.Type) + var severity models.AlertSeverity + var thresholdValue float64 + + if value >= threshold.CriticalValue { + severity = models.AlertSeverityCritical + thresholdValue = threshold.CriticalValue + } else if value >= threshold.WarningValue { + severity = models.AlertSeverityWarning + thresholdValue = threshold.WarningValue + } else { + // Value is below thresholds - resolve any active alert + delete(m.thresholdHits, alertKey) + if alert, exists := m.activeAlerts[alertKey]; exists { + resolvedAt := now + alert.ResolvedAt = &resolvedAt + m.addToHistory(*alert) + delete(m.activeAlerts, alertKey) + } + continue + } + + // Check duration requirement + firstHit, hasHit := m.thresholdHits[alertKey] + if !hasHit { + m.thresholdHits[alertKey] = now + if threshold.DurationSeconds > 0 { + continue // Wait for duration + } + firstHit = now + } + + elapsed := now.Sub(firstHit) + if elapsed.Seconds() < float64(threshold.DurationSeconds) { + continue // Duration not met yet + } + + // Check if we need to create/update alert + existingAlert, alertExists := m.activeAlerts[alertKey] + + if !alertExists { + // Create new alert + alert := &models.Alert{ + ID: fmt.Sprintf("%s-%d", threshold.Type, now.Unix()), + Type: threshold.Type, + Severity: severity, + Message: fmt.Sprintf("%s is at %.1f%% (threshold: %.1f%%)", label, value, thresholdValue), + Value: value, + Threshold: thresholdValue, + TriggeredAt: now, + } + m.activeAlerts[alertKey] = alert + newAlerts = append(newAlerts, *alert) + } else if existingAlert.Severity != severity { + // Severity changed - update alert + existingAlert.Severity = severity + existingAlert.Value = value + existingAlert.Threshold = thresholdValue + existingAlert.Message = fmt.Sprintf("%s is at %.1f%% (threshold: %.1f%%)", label, value, thresholdValue) + } else { + // Just update value + existingAlert.Value = value + } + } + + return newAlerts +} + +func (m *Manager) addToHistory(alert models.Alert) { + m.alertHistory = append([]models.Alert{alert}, m.alertHistory...) + if len(m.alertHistory) > m.maxHistory { + m.alertHistory = m.alertHistory[:m.maxHistory] + } +} diff --git a/backend/internal/alerts/manager_test.go b/backend/internal/alerts/manager_test.go new file mode 100644 index 0000000..e10ce2e --- /dev/null +++ b/backend/internal/alerts/manager_test.go @@ -0,0 +1,344 @@ +package alerts + +import ( + "testing" + "time" + + "system-monitor/internal/models" +) + +func TestNewManager(t *testing.T) { + manager := NewManager() + + if manager == nil { + t.Fatal("Expected non-nil manager") + } + + config := manager.GetConfig() + if len(config.Thresholds) == 0 { + t.Error("Expected default thresholds") + } + + alerts := manager.GetActiveAlerts() + if len(alerts) != 0 { + t.Errorf("Expected no active alerts, got %d", len(alerts)) + } +} + +func TestCheckMetrics_TriggersWarning(t *testing.T) { + manager := NewManager() + + // Set a low threshold for testing + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 0, // Immediate trigger + }, + }, + }) + + // Create metrics with high CPU + metrics := models.AllMetrics{ + CPU: models.CPUStats{ + TotalUsage: 60, // Above warning, below critical + }, + } + + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 1 { + t.Errorf("Expected 1 new alert, got %d", len(newAlerts)) + } + + if len(newAlerts) > 0 { + if newAlerts[0].Severity != models.AlertSeverityWarning { + t.Errorf("Expected warning severity, got %s", newAlerts[0].Severity) + } + if newAlerts[0].Type != models.AlertTypeCPU { + t.Errorf("Expected CPU alert type, got %s", newAlerts[0].Type) + } + } + + // Check active alerts + active := manager.GetActiveAlerts() + if len(active) != 1 { + t.Errorf("Expected 1 active alert, got %d", len(active)) + } +} + +func TestCheckMetrics_TriggersCritical(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + metrics := models.AllMetrics{ + CPU: models.CPUStats{ + TotalUsage: 95, // Above critical + }, + } + + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 1 { + t.Errorf("Expected 1 new alert, got %d", len(newAlerts)) + } + + if len(newAlerts) > 0 && newAlerts[0].Severity != models.AlertSeverityCritical { + t.Errorf("Expected critical severity, got %s", newAlerts[0].Severity) + } +} + +func TestCheckMetrics_ResolvesAlert(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + // Trigger an alert + highCPU := models.AllMetrics{ + CPU: models.CPUStats{TotalUsage: 60}, + } + manager.CheckMetrics(highCPU) + + if len(manager.GetActiveAlerts()) != 1 { + t.Error("Expected active alert after high CPU") + } + + // Resolve the alert with low CPU + lowCPU := models.AllMetrics{ + CPU: models.CPUStats{TotalUsage: 30}, + } + manager.CheckMetrics(lowCPU) + + if len(manager.GetActiveAlerts()) != 0 { + t.Error("Expected no active alerts after low CPU") + } + + // Should be in history + history := manager.GetAlertHistory() + if len(history) != 1 { + t.Errorf("Expected 1 alert in history, got %d", len(history)) + } +} + +func TestCheckMetrics_DisabledThreshold(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: false, // Disabled + DurationSeconds: 0, + }, + }, + }) + + metrics := models.AllMetrics{ + CPU: models.CPUStats{TotalUsage: 95}, + } + + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 0 { + t.Error("Expected no alerts for disabled threshold") + } +} + +func TestCheckMetrics_DurationRequirement(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 60, // Must exceed for 60 seconds + }, + }, + }) + + metrics := models.AllMetrics{ + CPU: models.CPUStats{TotalUsage: 60}, + } + + // First check - should not trigger immediately + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 0 { + t.Error("Expected no alerts before duration requirement met") + } +} + +func TestAcknowledgeAlert(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + metrics := models.AllMetrics{ + CPU: models.CPUStats{TotalUsage: 60}, + } + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 1 { + t.Fatalf("Expected 1 new alert, got %d", len(newAlerts)) + } + + alertID := newAlerts[0].ID + + // Acknowledge the alert using the ID from the returned alert + success := manager.AcknowledgeAlert(alertID) + if !success { + t.Error("Expected acknowledge to succeed") + } + + // Check it's acknowledged + alerts := manager.GetActiveAlerts() + if len(alerts) != 1 { + t.Fatal("Expected 1 active alert after acknowledge") + } + if !alerts[0].Acknowledged { + t.Error("Expected alert to be acknowledged") + } + + // Try acknowledging non-existent alert + success = manager.AcknowledgeAlert("non-existent") + if success { + t.Error("Expected acknowledge to fail for non-existent alert") + } +} + +func TestCheckMetrics_MemoryAlert(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeMemory, + WarningValue: 80, + CriticalValue: 95, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + // 90% memory usage + metrics := models.AllMetrics{ + Memory: models.MemoryStats{ + Total: 1000, + Used: 900, + }, + } + + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 1 { + t.Errorf("Expected 1 memory alert, got %d", len(newAlerts)) + } + + if len(newAlerts) > 0 && newAlerts[0].Type != models.AlertTypeMemory { + t.Errorf("Expected memory alert type, got %s", newAlerts[0].Type) + } +} + +func TestCheckMetrics_DiskAlert(t *testing.T) { + manager := NewManager() + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeDisk, + WarningValue: 80, + CriticalValue: 95, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + metrics := models.AllMetrics{ + Disk: models.DiskStats{ + Mounts: []models.MountStats{ + {MountPoint: "/", UsedPercent: 85}, + {MountPoint: "/home", UsedPercent: 50}, + }, + }, + } + + newAlerts := manager.CheckMetrics(metrics) + + if len(newAlerts) != 1 { + t.Errorf("Expected 1 disk alert, got %d", len(newAlerts)) + } +} + +func TestGetAlertHistory_MaxLimit(t *testing.T) { + manager := NewManager() + manager.maxHistory = 5 // Reduce for testing + + manager.SetConfig(models.AlertConfig{ + Thresholds: []models.AlertThreshold{ + { + Type: models.AlertTypeCPU, + WarningValue: 50, + CriticalValue: 90, + Enabled: true, + DurationSeconds: 0, + }, + }, + }) + + // Trigger and resolve multiple alerts + for i := 0; i < 10; i++ { + high := models.AllMetrics{CPU: models.CPUStats{TotalUsage: 60}} + manager.CheckMetrics(high) + + low := models.AllMetrics{CPU: models.CPUStats{TotalUsage: 30}} + manager.CheckMetrics(low) + + time.Sleep(time.Millisecond) // Ensure unique timestamps + } + + history := manager.GetAlertHistory() + if len(history) > 5 { + t.Errorf("Expected max 5 alerts in history, got %d", len(history)) + } +} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 626ab98..a4ff842 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -1,22 +1,66 @@ package api import ( + "encoding/json" "fmt" - "io" "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 + 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 { @@ -28,16 +72,17 @@ func NewServer(cfg *config.Config, broker *sse.Broker) *Server { router.Use(cors.New(cors.Config{ AllowOrigins: []string{"*"}, AllowMethods: []string{"GET", "POST", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Content-Type", "Accept"}, + 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, + router: router, + broker: broker, + cfg: cfg, + rateLimiter: NewRateLimiter(100, time.Minute), // 100 requests per minute } s.setupRoutes() @@ -45,23 +90,64 @@ func NewServer(cfg *config.Config, broker *sse.Broker) *Server { } func (s *Server) setupRoutes() { - // Health check + // 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"}) } @@ -71,41 +157,45 @@ func (s *Server) metricsHandler(c *gin.Context) { 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.Header("Content-Type", "text/event-stream") - c.Header("Cache-Control", "no-cache") - c.Header("Connection", "keep-alive") - c.Header("X-Accel-Buffering", "no") + 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) - // Handle client disconnect - notify := c.Request.Context().Done() - - go func() { - <-notify - s.broker.Unregister(clientChan) - }() + // Clean up on disconnect + defer s.broker.Unregister(clientChan) // Send initial data immediately initial := s.broker.CollectAll() - c.SSEvent("message", initial) - c.Writer.Flush() + initialJSON, err := json.Marshal(initial) + if err == nil { + fmt.Fprintf(c.Writer, "data: %s\n\n", initialJSON) + c.Writer.Flush() + } - // Stream data - write raw SSE format to avoid double-encoding - c.Stream(func(w io.Writer) bool { + // Stream data using Server-Sent Events + notify := c.Request.Context().Done() + for { select { case <-notify: - return false + return case data := <-clientChan: - // Write SSE format directly: "data: {json}\n\n" - fmt.Fprintf(w, "data: %s\n\n", data) - return true + fmt.Fprintf(c.Writer, "data: %s\n\n", data) + c.Writer.Flush() } - }) + } } type RefreshRequest struct { @@ -133,6 +223,138 @@ func (s *Server) getRefreshHandler(c *gin.Context) { 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"}) + } +} diff --git a/backend/internal/collectors/cpu_test.go b/backend/internal/collectors/cpu_test.go new file mode 100644 index 0000000..5202eea --- /dev/null +++ b/backend/internal/collectors/cpu_test.go @@ -0,0 +1,99 @@ +package collectors + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCPUCollector(t *testing.T) { + // Create temp directory with mock /proc files + tmpDir := t.TempDir() + procPath := filepath.Join(tmpDir, "proc") + sysPath := filepath.Join(tmpDir, "sys") + + // Create necessary directories + if err := os.MkdirAll(procPath, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(sysPath, "devices/system/cpu/cpufreq/policy0"), 0755); err != nil { + t.Fatal(err) + } + + // Create mock /proc/stat + statContent := `cpu 10132153 290696 3084719 46828483 16683 0 25195 0 0 0 +cpu0 1393280 32966 572056 13343292 6130 0 17875 0 0 0 +cpu1 1264380 32862 535089 13315801 3580 0 7275 0 0 0 +` + if err := os.WriteFile(filepath.Join(procPath, "stat"), []byte(statContent), 0644); err != nil { + t.Fatal(err) + } + + // Create mock /proc/loadavg + loadavgContent := "1.23 2.34 3.45 1/123 12345\n" + if err := os.WriteFile(filepath.Join(procPath, "loadavg"), []byte(loadavgContent), 0644); err != nil { + t.Fatal(err) + } + + // Create mock scaling_cur_freq + if err := os.WriteFile(filepath.Join(sysPath, "devices/system/cpu/cpufreq/policy0/scaling_cur_freq"), []byte("3500000\n"), 0644); err != nil { + t.Fatal(err) + } + + collector := NewCPUCollector(procPath, sysPath) + + // First collection to initialize + _, err := collector.Collect() + if err != nil { + t.Fatalf("First collect failed: %v", err) + } + + // Update stat with new values to calculate usage + statContent2 := `cpu 10142153 290696 3084719 46838483 16683 0 25195 0 0 0 +cpu0 1403280 32966 572056 13353292 6130 0 17875 0 0 0 +cpu1 1274380 32862 535089 13325801 3580 0 7275 0 0 0 +` + if err := os.WriteFile(filepath.Join(procPath, "stat"), []byte(statContent2), 0644); err != nil { + t.Fatal(err) + } + + // Second collection + stats, err := collector.Collect() + if err != nil { + t.Fatalf("Second collect failed: %v", err) + } + + // Verify results + if len(stats.Cores) == 0 { + t.Error("Expected at least one core") + } + + if stats.LoadAverage.Load1 != 1.23 { + t.Errorf("Expected load1=1.23, got %f", stats.LoadAverage.Load1) + } + + if stats.LoadAverage.Load5 != 2.34 { + t.Errorf("Expected load5=2.34, got %f", stats.LoadAverage.Load5) + } + + if stats.LoadAverage.Load15 != 3.45 { + t.Errorf("Expected load15=3.45, got %f", stats.LoadAverage.Load15) + } +} + +func TestCPUCollector_MissingFiles(t *testing.T) { + tmpDir := t.TempDir() + collector := NewCPUCollector(tmpDir, tmpDir) + + // Should handle gracefully - either error or empty stats is acceptable + stats, err := collector.Collect() + if err != nil { + // Error is acceptable for missing files + return + } + + // If no error, should have empty/default values + if len(stats.Cores) != 0 { + t.Errorf("Expected no cores with missing files, got %d", len(stats.Cores)) + } +} diff --git a/backend/internal/collectors/docker.go b/backend/internal/collectors/docker.go new file mode 100644 index 0000000..85c5e8e --- /dev/null +++ b/backend/internal/collectors/docker.go @@ -0,0 +1,201 @@ +package collectors + +import ( + "context" + "encoding/json" + "net" + "net/http" + "time" + + "system-monitor/internal/models" +) + +type DockerCollector struct { + client *http.Client + available bool + socketPath string +} + +func NewDockerCollector(socketPath string) *DockerCollector { + if socketPath == "" { + socketPath = "/var/run/docker.sock" + } + + // Create HTTP client that connects to Docker socket with short timeout + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + Timeout: 2 * time.Second, // Reduced from 5s + } + + c := &DockerCollector{client: client, socketPath: socketPath} + c.checkAvailable() + return c +} + +func (c *DockerCollector) checkAvailable() { + resp, err := c.client.Get("http://localhost/version") + if err != nil { + c.available = false + return + } + defer resp.Body.Close() + c.available = resp.StatusCode == http.StatusOK +} + +func (c *DockerCollector) Collect() (models.DockerStats, error) { + stats := models.DockerStats{ + Available: c.available, + Containers: []models.ContainerStats{}, + } + + if !c.available { + return stats, nil + } + + // Get container list + resp, err := c.client.Get("http://localhost/containers/json") + if err != nil { + c.available = false + return stats, nil + } + defer resp.Body.Close() + + var containers []struct { + ID string `json:"Id"` + Names []string `json:"Names"` + Image string `json:"Image"` + State string `json:"State"` + Status string `json:"Status"` + } + + if err := json.NewDecoder(resp.Body).Decode(&containers); err != nil { + return stats, nil + } + + // Build container list first (fast) + containerStats := make([]models.ContainerStats, len(containers)) + for i, container := range containers { + name := container.ID[:12] + if len(container.Names) > 0 { + name = container.Names[0] + if len(name) > 0 && name[0] == '/' { + name = name[1:] + } + } + + containerStats[i] = models.ContainerStats{ + ID: container.ID[:12], + Name: name, + Image: container.Image, + State: container.State, + Status: container.Status, + } + + if container.State == "running" { + stats.Running++ + } + } + + // Fetch detailed stats in parallel for running containers only + type statsResult struct { + index int + stats *detailedStats + } + resultChan := make(chan statsResult, len(containers)) + + for i, container := range containers { + if container.State != "running" { + continue + } + go func(idx int, containerID string) { + ds := c.fetchContainerStats(containerID) + resultChan <- statsResult{index: idx, stats: ds} + }(i, container.ID) + } + + // Collect results with timeout + timeout := time.After(1500 * time.Millisecond) + runningCount := stats.Running + collected := 0 + for collected < runningCount { + select { + case result := <-resultChan: + if result.stats != nil { + containerStats[result.index].CPUPercent = result.stats.cpuPercent + containerStats[result.index].MemoryUsage = result.stats.memUsage + containerStats[result.index].MemoryLimit = result.stats.memLimit + containerStats[result.index].MemoryPercent = result.stats.memPercent + } + collected++ + case <-timeout: + // Stop waiting, use whatever we have + goto done + } + } +done: + + stats.Containers = containerStats + stats.Total = len(containers) + return stats, nil +} + +type detailedStats struct { + cpuPercent float64 + memUsage uint64 + memLimit uint64 + memPercent float64 +} + +func (c *DockerCollector) fetchContainerStats(containerID string) *detailedStats { + statsResp, err := c.client.Get("http://localhost/containers/" + containerID + "/stats?stream=false") + if err != nil { + return nil + } + defer statsResp.Body.Close() + + var containerStats struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs int `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Limit uint64 `json:"limit"` + } `json:"memory_stats"` + } + + if err := json.NewDecoder(statsResp.Body).Decode(&containerStats); err != nil { + return nil + } + + ds := &detailedStats{ + memUsage: containerStats.MemoryStats.Usage, + memLimit: containerStats.MemoryStats.Limit, + } + + // Calculate CPU percentage + cpuDelta := float64(containerStats.CPUStats.CPUUsage.TotalUsage - containerStats.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(containerStats.CPUStats.SystemCPUUsage - containerStats.PreCPUStats.SystemCPUUsage) + if systemDelta > 0 && cpuDelta > 0 { + ds.cpuPercent = (cpuDelta / systemDelta) * float64(containerStats.CPUStats.OnlineCPUs) * 100 + } + + if ds.memLimit > 0 { + ds.memPercent = float64(ds.memUsage) / float64(ds.memLimit) * 100 + } + + return ds +} diff --git a/backend/internal/collectors/memory_test.go b/backend/internal/collectors/memory_test.go new file mode 100644 index 0000000..640368b --- /dev/null +++ b/backend/internal/collectors/memory_test.go @@ -0,0 +1,107 @@ +package collectors + +import ( + "os" + "path/filepath" + "testing" +) + +func TestMemoryCollector(t *testing.T) { + tmpDir := t.TempDir() + + // Create mock /proc/meminfo + meminfoContent := `MemTotal: 32768000 kB +MemFree: 10240000 kB +MemAvailable: 20480000 kB +Buffers: 1024000 kB +Cached: 5120000 kB +SwapCached: 0 kB +Active: 8192000 kB +Inactive: 4096000 kB +SwapTotal: 8192000 kB +SwapFree: 6144000 kB +` + if err := os.WriteFile(filepath.Join(tmpDir, "meminfo"), []byte(meminfoContent), 0644); err != nil { + t.Fatal(err) + } + + collector := NewMemoryCollector(tmpDir) + stats, err := collector.Collect() + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + // Verify values (kB to bytes) + expectedTotal := uint64(32768000 * 1024) + if stats.Total != expectedTotal { + t.Errorf("Expected Total=%d, got %d", expectedTotal, stats.Total) + } + + expectedAvailable := uint64(20480000 * 1024) + if stats.Available != expectedAvailable { + t.Errorf("Expected Available=%d, got %d", expectedAvailable, stats.Available) + } + + expectedCached := uint64(5120000 * 1024) + if stats.Cached != expectedCached { + t.Errorf("Expected Cached=%d, got %d", expectedCached, stats.Cached) + } + + expectedSwapTotal := uint64(8192000 * 1024) + if stats.SwapTotal != expectedSwapTotal { + t.Errorf("Expected SwapTotal=%d, got %d", expectedSwapTotal, stats.SwapTotal) + } + + expectedSwapUsed := uint64((8192000 - 6144000) * 1024) + if stats.SwapUsed != expectedSwapUsed { + t.Errorf("Expected SwapUsed=%d, got %d", expectedSwapUsed, stats.SwapUsed) + } + + // Used should be Total - Available + expectedUsed := expectedTotal - expectedAvailable + if stats.Used != expectedUsed { + t.Errorf("Expected Used=%d, got %d", expectedUsed, stats.Used) + } +} + +func TestMemoryCollector_MissingFile(t *testing.T) { + tmpDir := t.TempDir() + collector := NewMemoryCollector(tmpDir) + + stats, err := collector.Collect() + // Should return error or empty stats + if err == nil && stats.Total == 0 { + // This is acceptable - empty stats on missing file + return + } + if err != nil { + // This is also acceptable + return + } + t.Error("Expected either error or zero Total for missing meminfo") +} + +func TestMemoryCollector_MalformedFile(t *testing.T) { + tmpDir := t.TempDir() + + // Create malformed meminfo + meminfoContent := `MemTotal: invalid +MemFree: also invalid +` + if err := os.WriteFile(filepath.Join(tmpDir, "meminfo"), []byte(meminfoContent), 0644); err != nil { + t.Fatal(err) + } + + collector := NewMemoryCollector(tmpDir) + stats, err := collector.Collect() + + // Should handle gracefully + if err != nil { + return // Error is acceptable + } + + // If no error, values should be zero (failed to parse) + if stats.Total != 0 { + t.Errorf("Expected Total=0 for malformed input, got %d", stats.Total) + } +} diff --git a/backend/internal/collectors/network_test.go b/backend/internal/collectors/network_test.go new file mode 100644 index 0000000..58e2655 --- /dev/null +++ b/backend/internal/collectors/network_test.go @@ -0,0 +1,127 @@ +package collectors + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNetworkCollector(t *testing.T) { + tmpDir := t.TempDir() + + // Create mock net directory + netDir := filepath.Join(tmpDir, "net") + if err := os.MkdirAll(netDir, 0755); err != nil { + t.Fatal(err) + } + + // Create mock /proc/net/dev + devContent := `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 12345678 123456 0 0 0 0 0 0 12345678 123456 0 0 0 0 0 0 + eth0: 987654321 9876543 0 0 0 0 0 0 123456789 1234567 0 0 0 0 0 0 + docker0: 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +` + if err := os.WriteFile(filepath.Join(netDir, "dev"), []byte(devContent), 0644); err != nil { + t.Fatal(err) + } + + // Create mock /proc/net/tcp (for connection count) + tcpContent := ` sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:0019 00000000:0000 0A 00000000:00000000 00:00000000 00000000 33 0 23456 1 0000000000000000 100 0 0 10 0 +` + if err := os.WriteFile(filepath.Join(netDir, "tcp"), []byte(tcpContent), 0644); err != nil { + t.Fatal(err) + } + + collector := NewNetworkCollector(tmpDir) + stats, err := collector.Collect() + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + // Should have eth0 (excluding lo and docker0 virtual interfaces might depend on impl) + if len(stats.Interfaces) == 0 { + t.Error("Expected at least one interface") + } + + // Find eth0 + var eth0Found bool + for _, iface := range stats.Interfaces { + if iface.Name == "eth0" { + eth0Found = true + if iface.RxBytes != 987654321 { + t.Errorf("Expected eth0 RxBytes=987654321, got %d", iface.RxBytes) + } + if iface.TxBytes != 123456789 { + t.Errorf("Expected eth0 TxBytes=123456789, got %d", iface.TxBytes) + } + if iface.RxPackets != 9876543 { + t.Errorf("Expected eth0 RxPackets=9876543, got %d", iface.RxPackets) + } + if iface.TxPackets != 1234567 { + t.Errorf("Expected eth0 TxPackets=1234567, got %d", iface.TxPackets) + } + } + } + + if !eth0Found { + t.Error("eth0 interface not found") + } + + // Check connection count (2 connections in mock tcp file, minus header) + if stats.ConnectionCount != 2 { + t.Errorf("Expected ConnectionCount=2, got %d", stats.ConnectionCount) + } +} + +func TestNetworkCollector_MissingFiles(t *testing.T) { + tmpDir := t.TempDir() + collector := NewNetworkCollector(tmpDir) + + stats, err := collector.Collect() + // Should not panic + if err != nil { + return // Error is acceptable + } + + if len(stats.Interfaces) != 0 { + t.Errorf("Expected no interfaces with missing files, got %d", len(stats.Interfaces)) + } +} + +func TestNetworkCollector_ParsesVirtualInterfaces(t *testing.T) { + tmpDir := t.TempDir() + netDir := filepath.Join(tmpDir, "net") + if err := os.MkdirAll(netDir, 0755); err != nil { + t.Fatal(err) + } + + // Only virtual interfaces + devContent := `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 12345 1234 0 0 0 0 0 0 12345 1234 0 0 0 0 0 0 + veth123: 5000 50 0 0 0 0 0 0 6000 60 0 0 0 0 0 0 + br-abc: 7000 70 0 0 0 0 0 0 8000 80 0 0 0 0 0 0 +` + if err := os.WriteFile(filepath.Join(netDir, "dev"), []byte(devContent), 0644); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(filepath.Join(netDir, "tcp"), []byte(""), 0644); err != nil { + t.Fatal(err) + } + + collector := NewNetworkCollector(tmpDir) + stats, err := collector.Collect() + if err != nil { + t.Fatalf("Collect failed: %v", err) + } + + // Should parse all interfaces (implementation may or may not filter virtual) + // At minimum, should not crash and should handle the format correctly + if stats.Interfaces == nil { + t.Error("Expected non-nil Interfaces") + } +} diff --git a/backend/internal/collectors/systemd.go b/backend/internal/collectors/systemd.go new file mode 100644 index 0000000..05c446b --- /dev/null +++ b/backend/internal/collectors/systemd.go @@ -0,0 +1,142 @@ +package collectors + +import ( + "context" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "system-monitor/internal/models" +) + +const dbusTimeout = 2 * time.Second + +type SystemdCollector struct { + conn *dbus.Conn + available bool +} + +func NewSystemdCollector() *SystemdCollector { + c := &SystemdCollector{} + c.connect() + return c +} + +func (c *SystemdCollector) connect() { + // Try to connect to the system bus + conn, err := dbus.SystemBus() + if err != nil { + c.available = false + return + } + c.conn = conn + c.available = true +} + +func (c *SystemdCollector) Collect() (models.SystemdStats, error) { + stats := models.SystemdStats{ + Available: c.available, + Services: []models.ServiceStatus{}, + } + + if !c.available || c.conn == nil { + return stats, nil + } + + // Create a context with timeout for the D-Bus call + ctx, cancel := context.WithTimeout(context.Background(), dbusTimeout) + defer cancel() + + // Call ListUnits on systemd manager with timeout + obj := c.conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1") + call := obj.CallWithContext(ctx, "org.freedesktop.systemd1.Manager.ListUnits", 0) + if call.Err != nil { + // Connection might have been lost or timed out, mark as unavailable + c.available = false + return stats, nil // Return empty stats instead of error + } + + // ListUnits returns array of structs: + // (name, description, load_state, active_state, sub_state, following, unit_path, job_id, job_type, job_path) + var units [][]interface{} + if err := call.Store(&units); err != nil { + return stats, nil // Return empty stats instead of error + } + + for _, unit := range units { + if len(unit) < 5 { + continue + } + + name, ok := unit[0].(string) + if !ok { + continue + } + + // Only process .service units + if !strings.HasSuffix(name, ".service") { + continue + } + + // Remove .service suffix for cleaner display + name = strings.TrimSuffix(name, ".service") + + // Skip template services + if strings.Contains(name, "@") && !strings.Contains(name, "@.") { + continue + } + + load, _ := unit[2].(string) + active, _ := unit[3].(string) + sub, _ := unit[4].(string) + + service := models.ServiceStatus{ + Name: name, + Load: load, + Active: active, + Sub: sub, + } + + // Count by status + switch active { + case "active": + stats.Active++ + case "inactive": + stats.Inactive++ + case "failed": + stats.Failed++ + } + + stats.Services = append(stats.Services, service) + } + + stats.Total = len(stats.Services) + + // Sort failed services first, then active running, then other active + // Limit to 50 most relevant services + sortedServices := make([]models.ServiceStatus, 0, 50) + + // Add failed first + for _, s := range stats.Services { + if s.Active == "failed" && len(sortedServices) < 50 { + sortedServices = append(sortedServices, s) + } + } + + // Add active running + for _, s := range stats.Services { + if s.Active == "active" && s.Sub == "running" && len(sortedServices) < 50 { + sortedServices = append(sortedServices, s) + } + } + + // Add other active + for _, s := range stats.Services { + if s.Active == "active" && s.Sub != "running" && len(sortedServices) < 50 { + sortedServices = append(sortedServices, s) + } + } + + stats.Services = sortedServices + return stats, nil +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 2c4cfaf..2ffa321 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -3,29 +3,111 @@ package config import ( "os" "time" + + "gopkg.in/yaml.v3" ) type Config struct { - Port string - RefreshInterval time.Duration - ProcPath string - SysPath string - MtabPath string + // Server settings + Port string `yaml:"port"` + RefreshInterval time.Duration `yaml:"-"` + RefreshSeconds int `yaml:"refresh_interval"` + + // Paths for containerized access + ProcPath string `yaml:"proc_path"` + SysPath string `yaml:"sys_path"` + MtabPath string `yaml:"mtab_path"` + DockerSock string `yaml:"docker_socket"` + + // Authentication + AuthEnabled bool `yaml:"auth_enabled"` + AuthUser string `yaml:"auth_user"` + AuthPass string `yaml:"auth_pass"` + + // TLS + TLSEnabled bool `yaml:"tls_enabled"` + TLSCertFile string `yaml:"tls_cert_file"` + TLSKeyFile string `yaml:"tls_key_file"` + + // Alerts + Alerts AlertConfig `yaml:"alerts"` +} + +type AlertConfig struct { + Enabled bool `yaml:"enabled"` + CPUThreshold float64 `yaml:"cpu_threshold"` + MemoryThreshold float64 `yaml:"memory_threshold"` + DiskThreshold float64 `yaml:"disk_threshold"` + TempThreshold float64 `yaml:"temp_threshold"` } func Load() *Config { - interval, err := time.ParseDuration(getEnv("DEFAULT_REFRESH_INTERVAL", "5s")) - if err != nil { - interval = 5 * time.Second + cfg := &Config{ + Port: "8080", + RefreshSeconds: 5, + ProcPath: "/proc", + SysPath: "/sys", + MtabPath: "/etc/mtab", + DockerSock: "/var/run/docker.sock", + Alerts: AlertConfig{ + CPUThreshold: 90.0, + MemoryThreshold: 90.0, + DiskThreshold: 90.0, + TempThreshold: 80.0, + }, } - return &Config{ - Port: getEnv("PORT", "8080"), - RefreshInterval: interval, - ProcPath: getEnv("PROC_PATH", "/proc"), - SysPath: getEnv("SYS_PATH", "/sys"), - MtabPath: getEnv("MTAB_PATH", "/etc/mtab"), + // Try to load from YAML config file + configPath := getEnv("CONFIG_FILE", "/etc/sysmon/config.yaml") + if data, err := os.ReadFile(configPath); err == nil { + yaml.Unmarshal(data, cfg) } + + // Environment variables override YAML + if val := os.Getenv("PORT"); val != "" { + cfg.Port = val + } + if val := os.Getenv("PROC_PATH"); val != "" { + cfg.ProcPath = val + } + if val := os.Getenv("SYS_PATH"); val != "" { + cfg.SysPath = val + } + if val := os.Getenv("MTAB_PATH"); val != "" { + cfg.MtabPath = val + } + if val := os.Getenv("DOCKER_SOCKET"); val != "" { + cfg.DockerSock = val + } + if val := os.Getenv("AUTH_ENABLED"); val == "true" { + cfg.AuthEnabled = true + } + if val := os.Getenv("AUTH_USER"); val != "" { + cfg.AuthUser = val + } + if val := os.Getenv("AUTH_PASS"); val != "" { + cfg.AuthPass = val + } + if val := os.Getenv("TLS_ENABLED"); val == "true" { + cfg.TLSEnabled = true + } + if val := os.Getenv("TLS_CERT_FILE"); val != "" { + cfg.TLSCertFile = val + } + if val := os.Getenv("TLS_KEY_FILE"); val != "" { + cfg.TLSKeyFile = val + } + + // Parse refresh interval + if intervalStr := os.Getenv("DEFAULT_REFRESH_INTERVAL"); intervalStr != "" { + if d, err := time.ParseDuration(intervalStr); err == nil { + cfg.RefreshInterval = d + } + } else { + cfg.RefreshInterval = time.Duration(cfg.RefreshSeconds) * time.Second + } + + return cfg } func getEnv(key, defaultVal string) string { diff --git a/backend/internal/history/history.go b/backend/internal/history/history.go new file mode 100644 index 0000000..2843710 --- /dev/null +++ b/backend/internal/history/history.go @@ -0,0 +1,116 @@ +package history + +import ( + "sync" + "time" +) + +// DataPoint represents a single metric value at a point in time +type DataPoint struct { + Timestamp time.Time `json:"timestamp"` + Value float64 `json:"value"` +} + +// MetricHistory stores historical data for a single metric +type MetricHistory struct { + Points []DataPoint `json:"points"` + MaxSize int `json:"-"` + mu sync.RWMutex +} + +// NewMetricHistory creates a new history buffer +func NewMetricHistory(maxSize int) *MetricHistory { + return &MetricHistory{ + Points: make([]DataPoint, 0, maxSize), + MaxSize: maxSize, + } +} + +// Add adds a new data point +func (h *MetricHistory) Add(value float64) { + h.mu.Lock() + defer h.mu.Unlock() + + point := DataPoint{ + Timestamp: time.Now(), + Value: value, + } + + h.Points = append(h.Points, point) + if len(h.Points) > h.MaxSize { + h.Points = h.Points[1:] + } +} + +// GetAll returns all data points +func (h *MetricHistory) GetAll() []DataPoint { + h.mu.RLock() + defer h.mu.RUnlock() + + result := make([]DataPoint, len(h.Points)) + copy(result, h.Points) + return result +} + +// GetSince returns data points since the given time +func (h *MetricHistory) GetSince(since time.Time) []DataPoint { + h.mu.RLock() + defer h.mu.RUnlock() + + var result []DataPoint + for _, p := range h.Points { + if p.Timestamp.After(since) { + result = append(result, p) + } + } + return result +} + +// HistoryStore manages multiple metric histories +type HistoryStore struct { + CPU *MetricHistory `json:"cpu"` + Memory *MetricHistory `json:"memory"` + GPU *MetricHistory `json:"gpu"` + NetworkRx *MetricHistory `json:"networkRx"` + NetworkTx *MetricHistory `json:"networkTx"` + DiskRead *MetricHistory `json:"diskRead"` + DiskWrite *MetricHistory `json:"diskWrite"` +} + +// NewHistoryStore creates a new history store with 1 hour of data at 1s resolution +func NewHistoryStore() *HistoryStore { + maxPoints := 3600 // 1 hour at 1 second intervals + return &HistoryStore{ + CPU: NewMetricHistory(maxPoints), + Memory: NewMetricHistory(maxPoints), + GPU: NewMetricHistory(maxPoints), + NetworkRx: NewMetricHistory(maxPoints), + NetworkTx: NewMetricHistory(maxPoints), + DiskRead: NewMetricHistory(maxPoints), + DiskWrite: NewMetricHistory(maxPoints), + } +} + +// HistoryResponse is the API response format +type HistoryResponse struct { + CPU []DataPoint `json:"cpu"` + Memory []DataPoint `json:"memory"` + GPU []DataPoint `json:"gpu"` + NetworkRx []DataPoint `json:"networkRx"` + NetworkTx []DataPoint `json:"networkTx"` + DiskRead []DataPoint `json:"diskRead"` + DiskWrite []DataPoint `json:"diskWrite"` +} + +// GetAll returns all history data +func (s *HistoryStore) GetAll() HistoryResponse { + return HistoryResponse{ + CPU: s.CPU.GetAll(), + Memory: s.Memory.GetAll(), + GPU: s.GPU.GetAll(), + NetworkRx: s.NetworkRx.GetAll(), + NetworkTx: s.NetworkTx.GetAll(), + DiskRead: s.DiskRead.GetAll(), + DiskWrite: s.DiskWrite.GetAll(), + } +} diff --git a/backend/internal/models/alerts.go b/backend/internal/models/alerts.go new file mode 100644 index 0000000..1860406 --- /dev/null +++ b/backend/internal/models/alerts.go @@ -0,0 +1,69 @@ +package models + +import "time" + +// AlertType represents different types of metrics that can trigger alerts +type AlertType string + +const ( + AlertTypeCPU AlertType = "cpu" + AlertTypeMemory AlertType = "memory" + AlertTypeTemperature AlertType = "temperature" + AlertTypeDisk AlertType = "disk" + AlertTypeGPU AlertType = "gpu" +) + +// AlertSeverity indicates the urgency level +type AlertSeverity string + +const ( + AlertSeverityWarning AlertSeverity = "warning" + AlertSeverityCritical AlertSeverity = "critical" +) + +// AlertThreshold defines when an alert should trigger +type AlertThreshold struct { + Type AlertType `json:"type"` + WarningValue float64 `json:"warningValue"` // Trigger warning at this value + CriticalValue float64 `json:"criticalValue"` // Trigger critical at this value + Enabled bool `json:"enabled"` + DurationSeconds int `json:"durationSeconds"` // Must exceed threshold for this long +} + +// Alert represents an active or historical alert +type Alert struct { + ID string `json:"id"` + Type AlertType `json:"type"` + Severity AlertSeverity `json:"severity"` + Message string `json:"message"` + Value float64 `json:"value"` + Threshold float64 `json:"threshold"` + TriggeredAt time.Time `json:"triggeredAt"` + ResolvedAt *time.Time `json:"resolvedAt,omitempty"` + Acknowledged bool `json:"acknowledged"` +} + +// AlertConfig holds all alert thresholds +type AlertConfig struct { + Thresholds []AlertThreshold `json:"thresholds"` +} + +// AlertsResponse is the API response for alerts +type AlertsResponse struct { + Active []Alert `json:"active"` + History []Alert `json:"history"` + Config AlertConfig `json:"config"` +} + +// DefaultAlertConfig returns sensible default thresholds +func DefaultAlertConfig() AlertConfig { + return AlertConfig{ + Thresholds: []AlertThreshold{ + {Type: AlertTypeCPU, WarningValue: 80, CriticalValue: 95, Enabled: true, DurationSeconds: 30}, + {Type: AlertTypeMemory, WarningValue: 85, CriticalValue: 95, Enabled: true, DurationSeconds: 30}, + {Type: AlertTypeTemperature, WarningValue: 75, CriticalValue: 90, Enabled: true, DurationSeconds: 10}, + {Type: AlertTypeDisk, WarningValue: 85, CriticalValue: 95, Enabled: true, DurationSeconds: 0}, + {Type: AlertTypeGPU, WarningValue: 85, CriticalValue: 95, Enabled: true, DurationSeconds: 30}, + }, + } +} diff --git a/backend/internal/models/docker.go b/backend/internal/models/docker.go new file mode 100644 index 0000000..f008f54 --- /dev/null +++ b/backend/internal/models/docker.go @@ -0,0 +1,20 @@ +package models + +type ContainerStats struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + CPUPercent float64 `json:"cpuPercent"` + MemoryUsage uint64 `json:"memoryUsage"` + MemoryLimit uint64 `json:"memoryLimit"` + MemoryPercent float64 `json:"memoryPercent"` +} + +type DockerStats struct { + Available bool `json:"available"` + Total int `json:"total"` + Running int `json:"running"` + Containers []ContainerStats `json:"containers"` +} diff --git a/backend/internal/models/system.go b/backend/internal/models/system.go index adf5405..614bf9e 100644 --- a/backend/internal/models/system.go +++ b/backend/internal/models/system.go @@ -20,4 +20,6 @@ type AllMetrics struct { Processes ProcessStats `json:"processes"` Temperature TemperatureStats `json:"temperature"` GPU AMDGPUStats `json:"gpu"` + Docker DockerStats `json:"docker"` + Systemd SystemdStats `json:"systemd"` } diff --git a/backend/internal/models/systemd.go b/backend/internal/models/systemd.go new file mode 100644 index 0000000..e1e08dc --- /dev/null +++ b/backend/internal/models/systemd.go @@ -0,0 +1,17 @@ +package models + +type ServiceStatus struct { + Name string `json:"name"` + Load string `json:"load"` + Active string `json:"active"` + Sub string `json:"sub"` +} + +type SystemdStats struct { + Available bool `json:"available"` + Total int `json:"total"` + Active int `json:"active"` + Inactive int `json:"inactive"` + Failed int `json:"failed"` + Services []ServiceStatus `json:"services"` +} diff --git a/backend/internal/sse/broker.go b/backend/internal/sse/broker.go index f3efa93..a778f4b 100644 --- a/backend/internal/sse/broker.go +++ b/backend/internal/sse/broker.go @@ -5,8 +5,10 @@ import ( "sync" "time" + "system-monitor/internal/alerts" "system-monitor/internal/collectors" "system-monitor/internal/config" + "system-monitor/internal/history" "system-monitor/internal/models" ) @@ -19,6 +21,18 @@ type Broker struct { interval time.Duration cfg *config.Config + // History store + History *history.HistoryStore + + // Alerts manager + Alerts *alerts.Manager + + // Previous values for rate calculations + prevNetRx uint64 + prevNetTx uint64 + prevDiskRead uint64 + prevDiskWrite uint64 + // Collectors system *collectors.SystemCollector cpu *collectors.CPUCollector @@ -28,24 +42,30 @@ type Broker struct { processes *collectors.ProcessCollector temperature *collectors.TemperatureCollector gpu *collectors.AMDGPUCollector + docker *collectors.DockerCollector + systemd *collectors.SystemdCollector } func NewBroker(cfg *config.Config) *Broker { return &Broker{ clients: make(map[chan []byte]bool), - register: make(chan chan []byte), - unregister: make(chan chan []byte), + register: make(chan chan []byte, 10), // Buffered to prevent blocking + unregister: make(chan chan []byte, 10), // Buffered to prevent blocking intervalChange: make(chan time.Duration, 1), interval: cfg.RefreshInterval, - cfg: cfg, - system: collectors.NewSystemCollector(cfg.ProcPath), - cpu: collectors.NewCPUCollector(cfg.ProcPath, cfg.SysPath), - memory: collectors.NewMemoryCollector(cfg.ProcPath), - disk: collectors.NewDiskCollector(cfg.ProcPath, cfg.MtabPath), - network: collectors.NewNetworkCollector(cfg.ProcPath), - processes: collectors.NewProcessCollector(cfg.ProcPath), - temperature: collectors.NewTemperatureCollector(cfg.SysPath), - gpu: collectors.NewAMDGPUCollector(cfg.SysPath), + cfg: cfg, + History: history.NewHistoryStore(), + Alerts: alerts.NewManager(), + system: collectors.NewSystemCollector(cfg.ProcPath), + cpu: collectors.NewCPUCollector(cfg.ProcPath, cfg.SysPath), + memory: collectors.NewMemoryCollector(cfg.ProcPath), + disk: collectors.NewDiskCollector(cfg.ProcPath, cfg.MtabPath), + network: collectors.NewNetworkCollector(cfg.ProcPath), + processes: collectors.NewProcessCollector(cfg.ProcPath), + temperature: collectors.NewTemperatureCollector(cfg.SysPath), + gpu: collectors.NewAMDGPUCollector(cfg.SysPath), + docker: collectors.NewDockerCollector(cfg.DockerSock), + systemd: collectors.NewSystemdCollector(), } } @@ -72,9 +92,16 @@ func (b *Broker) Run() { ticker.Reset(newInterval) case <-ticker.C: + metrics := b.collectAll() + + // Store in history + b.recordHistory(metrics) + + // Check alert thresholds + b.Alerts.CheckMetrics(metrics) + b.mu.RLock() if len(b.clients) > 0 { - metrics := b.collectAll() data, err := json.Marshal(metrics) if err == nil { for client := range b.clients { @@ -91,6 +118,52 @@ func (b *Broker) Run() { } } +func (b *Broker) recordHistory(m models.AllMetrics) { + // CPU usage + b.History.CPU.Add(m.CPU.TotalUsage) + + // Memory usage percent + if m.Memory.Total > 0 { + memPercent := float64(m.Memory.Used) / float64(m.Memory.Total) * 100 + b.History.Memory.Add(memPercent) + } + + // GPU usage + if m.GPU.Available { + b.History.GPU.Add(float64(m.GPU.Utilization)) + } + + // Network rates (bytes/sec) + var totalRx, totalTx uint64 + for _, iface := range m.Network.Interfaces { + totalRx += iface.RxBytes + totalTx += iface.TxBytes + } + if b.prevNetRx > 0 { + rxRate := float64(totalRx-b.prevNetRx) / b.interval.Seconds() + txRate := float64(totalTx-b.prevNetTx) / b.interval.Seconds() + b.History.NetworkRx.Add(rxRate) + b.History.NetworkTx.Add(txRate) + } + b.prevNetRx = totalRx + b.prevNetTx = totalTx + + // Disk I/O rates (bytes/sec) + var totalRead, totalWrite uint64 + for _, io := range m.Disk.IO { + totalRead += io.ReadBytes + totalWrite += io.WriteBytes + } + if b.prevDiskRead > 0 { + readRate := float64(totalRead-b.prevDiskRead) / b.interval.Seconds() + writeRate := float64(totalWrite-b.prevDiskWrite) / b.interval.Seconds() + b.History.DiskRead.Add(readRate) + b.History.DiskWrite.Add(writeRate) + } + b.prevDiskRead = totalRead + b.prevDiskWrite = totalWrite +} + func (b *Broker) Register(client chan []byte) { b.register <- client } @@ -159,5 +232,13 @@ func (b *Broker) collectAll() models.AllMetrics { metrics.GPU = gpu } + if docker, err := b.docker.Collect(); err == nil { + metrics.Docker = docker + } + + if systemd, err := b.systemd.Collect(); err == nil { + metrics.Systemd = systemd + } + return metrics } diff --git a/docker-compose.yaml b/docker-compose.yaml index 1967a6a..0311532 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,16 +5,23 @@ services: dockerfile: Dockerfile container_name: sysmon-backend restart: unless-stopped + ports: + - "9848:8080" environment: - PORT=8080 - PROC_PATH=/host/proc - SYS_PATH=/host/sys - MTAB_PATH=/host/etc/mtab + - DOCKER_SOCKET=/var/run/docker.sock - DEFAULT_REFRESH_INTERVAL=5s volumes: - /proc:/host/proc:ro - /sys:/host/sys:ro - /etc/mtab:/host/etc/mtab:ro + # Docker socket for container monitoring (optional) + - /var/run/docker.sock:/var/run/docker.sock:ro + # D-Bus socket for systemd monitoring (optional) + - /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro networks: - sysmon diff --git a/frontend/nginx.conf b/frontend/nginx.conf index e8fa0a7..bf13e89 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -16,16 +16,20 @@ server { location /api/ { proxy_pass http://backend:8080; proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - # SSE specific settings + # SSE specific settings - critical for streaming proxy_set_header X-Accel-Buffering no; proxy_buffering off; + proxy_cache off; + chunked_transfer_encoding off; proxy_read_timeout 86400s; proxy_send_timeout 86400s; + + # Keep connection open for SSE + proxy_set_header Connection ''; } # Health check @@ -33,6 +37,11 @@ server { proxy_pass http://backend:8080; } + # Prometheus metrics endpoint + location /metrics { + proxy_pass http://backend:8080; + } + # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { expires 1y; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..66fb819 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2593 @@ +{ + "name": "system-monitor-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "system-monitor-frontend", + "version": "0.0.1", + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.6", + "@sveltejs/kit": "^2.9.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "svelte": "^5.11.0", + "svelte-check": "^4.1.0", + "tailwindcss": "^3.4.16", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.8.tgz", + "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-static": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz", + "integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.49.2", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.49.2.tgz", + "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.3.2", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.1.tgz", + "integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.1.tgz", + "integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.46.1.tgz", + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.5.0", + "esm-env": "^1.2.1", + "esrap": "^2.2.1", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.5.tgz", + "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/tailwindcss/node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/tailwindcss/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitefu": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/src/app.css b/frontend/src/app.css index 38d3c61..0a9d2b8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -10,26 +10,61 @@ --gradient-4: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%); --gradient-5: linear-gradient(135deg, #fa709a 0%, #fee140 100%); --gradient-6: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); + + /* Dark theme (default) */ + --bg-primary: #0f0f1a; + --bg-secondary: #1a1a2e; + --bg-tertiary: #16213e; + --bg-card: rgba(30, 41, 59, 0.5); + --bg-card-hover: rgba(30, 41, 59, 0.7); + --border-color: rgba(255, 255, 255, 0.08); + --border-hover: rgba(255, 255, 255, 0.12); + --text-primary: #ffffff; + --text-secondary: #94a3b8; + --text-muted: #64748b; + } + + :root.light { + --bg-primary: #f8fafc; + --bg-secondary: #f1f5f9; + --bg-tertiary: #e2e8f0; + --bg-card: rgba(255, 255, 255, 0.9); + --bg-card-hover: rgba(255, 255, 255, 1); + --border-color: rgba(0, 0, 0, 0.1); + --border-hover: rgba(0, 0, 0, 0.15); + --text-primary: #0f172a; + --text-secondary: #334155; + --text-muted: #64748b; } body { @apply antialiased; - background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); + background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 50%, var(--bg-tertiary) 100%); min-height: 100vh; + min-height: 100dvh; /* Dynamic viewport height for mobile browsers */ + color: var(--text-primary); + transition: background 0.3s ease, color 0.3s ease; + /* Safe area insets for notched devices */ + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); } * { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.1) transparent; } + + .light * { + scrollbar-color: rgba(0, 0, 0, 0.1) transparent; + } } @layer components { .card { - @apply rounded-2xl p-5 relative overflow-hidden; - background: rgba(30, 41, 59, 0.5); + @apply rounded-xl sm:rounded-2xl p-4 sm:p-5 relative overflow-hidden; + background: var(--bg-card); backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--border-color); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2), @@ -37,28 +72,57 @@ transition: all 0.3s ease; } + .light .card { + box-shadow: + 0 4px 6px -1px rgba(0, 0, 0, 0.1), + 0 2px 4px -2px rgba(0, 0, 0, 0.05); + } + .card:hover { - border-color: rgba(255, 255, 255, 0.12); + border-color: var(--border-hover); + background: var(--bg-card-hover); box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.08); - transform: translateY(-2px); + } + + /* Only apply hover transform on non-touch devices */ + @media (hover: hover) { + .card:hover { + transform: translateY(-2px); + } + } + + .light .card:hover { + box-shadow: + 0 10px 15px -3px rgba(0, 0, 0, 0.1), + 0 4px 6px -4px rgba(0, 0, 0, 0.05); } .card-title { - @apply text-lg font-semibold mb-4 flex items-center gap-2; + @apply text-base sm:text-lg font-semibold mb-3 sm:mb-4 flex items-center gap-2; background: linear-gradient(135deg, #fff 0%, #a0aec0 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } + .light .card-title { + background: linear-gradient(135deg, #1e293b 0%, #475569 100%); + -webkit-background-clip: text; + background-clip: text; + } + .progress-bar { @apply h-2 rounded-full overflow-hidden; background: rgba(255, 255, 255, 0.1); } + .light .progress-bar { + background: rgba(0, 0, 0, 0.08); + } + .progress-fill { @apply h-full rounded-full transition-all duration-500 ease-out; box-shadow: 0 0 10px currentColor; @@ -100,16 +164,32 @@ background-clip: text; } + .light .stat-value { + background: linear-gradient(135deg, #0f172a 0%, #334155 100%); + -webkit-background-clip: text; + background-clip: text; + } + .stat-label { @apply text-xs uppercase tracking-wider text-slate-400; } + .light .stat-label { + @apply text-slate-600; + } + .metric-badge { @apply px-2 py-1 rounded-lg text-xs font-medium; background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.05); } + .light .metric-badge { + background: rgba(0, 0, 0, 0.05); + border: 1px solid rgba(0, 0, 0, 0.1); + color: #334155; + } + .glow-text { text-shadow: 0 0 20px currentColor; } @@ -137,6 +217,10 @@ min-height: 50px; } + .light .core-bar { + background: rgba(0, 0, 0, 0.04); + } + .core-fill { @apply absolute bottom-0 left-0 right-0 transition-all duration-300; border-radius: 0 0 0.5rem 0.5rem; @@ -158,4 +242,55 @@ .connected-indicator { animation: pulse-glow 2s ease-in-out infinite; } + + .light .table-row:hover { + background: rgba(0, 0, 0, 0.03); + } +} + +/* Light theme text color overrides */ +@layer utilities { + /* Background opacity overrides */ + .light .bg-white\/5 { + background-color: rgba(0, 0, 0, 0.04) !important; + } + + .light .bg-white\/\[0\.02\], + .light .bg-white\/\[0\.03\], + .light .bg-white\/\[0\.04\] { + background-color: rgba(0, 0, 0, 0.03) !important; + } + + /* Text color overrides for light theme */ + .light .text-slate-200 { + color: #1e293b !important; + } + + .light .text-slate-300 { + color: #334155 !important; + } + + .light .text-slate-400 { + color: #475569 !important; + } + + .light .text-slate-500 { + color: #64748b !important; + } + + .light .text-white { + color: #0f172a !important; + } + + /* Border overrides */ + .light .border-white\/5, + .light .border-white\/10 { + border-color: rgba(0, 0, 0, 0.08) !important; + } + + /* Hover background overrides */ + .light .hover\:bg-white\/\[0\.02\]:hover, + .light .hover\:bg-white\/\[0\.04\]:hover { + background-color: rgba(0, 0, 0, 0.05) !important; + } } diff --git a/frontend/src/app.html b/frontend/src/app.html index d87569b..4890cc7 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -5,6 +5,18 @@ System Monitor + + + + + + + + + + + + %sveltekit.head% diff --git a/frontend/src/lib/api/sse.ts b/frontend/src/lib/api/sse.ts index cd3a37d..5ace487 100644 --- a/frontend/src/lib/api/sse.ts +++ b/frontend/src/lib/api/sse.ts @@ -1,15 +1,34 @@ -import { metrics, connected } from '$lib/stores/metrics'; +import { metrics, connected, historyData } from '$lib/stores/metrics'; import { browser } from '$app/environment'; -import type { AllMetrics } from '$lib/types/metrics'; +import type { AllMetrics, HistoryData } from '$lib/types/metrics'; let eventSource: EventSource | null = null; let reconnectTimeout: ReturnType | null = null; +let historyInterval: ReturnType | null = null; + +async function fetchHistory() { + try { + const response = await fetch('/api/v1/history'); + if (response.ok) { + const data: HistoryData = await response.json(); + historyData.set(data); + } + } catch (e) { + console.error('Failed to fetch history:', e); + } +} export function connectSSE() { if (!browser) return; disconnectSSE(); + // Fetch initial history data + fetchHistory(); + + // Refresh history every 30 seconds + historyInterval = setInterval(fetchHistory, 30000); + const url = '/api/v1/stream'; eventSource = new EventSource(url); @@ -45,6 +64,11 @@ export function disconnectSSE() { reconnectTimeout = null; } + if (historyInterval) { + clearInterval(historyInterval); + historyInterval = null; + } + if (eventSource) { eventSource.close(); eventSource = null; diff --git a/frontend/src/lib/components/DashboardEditor.svelte b/frontend/src/lib/components/DashboardEditor.svelte new file mode 100644 index 0000000..ffebb63 --- /dev/null +++ b/frontend/src/lib/components/DashboardEditor.svelte @@ -0,0 +1,288 @@ + + + +
+ +
+
+
+
+ + + +
+
+

+ Dashboard Editor +

+

+ Drag cards to reorganize • Click eye to hide +

+
+
+ +
+ + +
+
+
+ + +
+
+ {#each $layout as section (section.id)} +
handleSectionDragOver(e, section.id)} + ondragleave={handleDragLeave} + ondrop={(e) => handleSectionDrop(e, section.id)} + role="region" + aria-label={section.name} + > + +
+
+
+

+ {section.name} +

+

+ {section.description} +

+
+ + {section.cards.filter((c) => c.visible).length} / {section.cards.length} cards + +
+
+ + +
+ {#if section.cards.length === 0} +
+

Drop cards here

+
+ {:else} +
+ {#each section.cards as card, index (card.id)} + {@const info = getCardInfo(card.id)} + {@const isDragging = draggedCard?.cardId === card.id} + {@const isDropTarget = dropTarget?.sectionId === section.id && dropTarget?.index === index} + +
handleDragStart(e, card.id, section.id, index)} + ondragend={handleDragEnd} + ondragover={(e) => handleDragOver(e, section.id, index)} + ondrop={(e) => handleDrop(e, section.id, index)} + > +
+
{info.icon}
+
+ {info.name} +
+
+ + + + + +
+ + + +
+
+ {/each} +
+ {/if} +
+
+ {/each} +
+
+ + + {#if $hiddenCards.length > 0} +
+
+
+ + Hidden ({$hiddenCards.length}): + +
+ {#each $hiddenCards as card (card.id)} + {@const info = getCardInfo(card.id)} + + {/each} +
+
+
+
+ {/if} +
diff --git a/frontend/src/lib/components/Header.svelte b/frontend/src/lib/components/Header.svelte index ab4e13f..ffe4ef9 100644 --- a/frontend/src/lib/components/Header.svelte +++ b/frontend/src/lib/components/Header.svelte @@ -1,57 +1,102 @@ -
-
-
+
+
+
-
+
-
-
- +
+
+
-

+

System Monitor

{#if $systemInfo} -

{$systemInfo.hostname}

+

{$systemInfo.hostname}

{/if}
- + {#if $systemInfo} - -
+ + + + + {#if mobileMenuOpen} +
+ + {#if $systemInfo} +
+ {$systemInfo.kernel} + Up: {formatUptime($systemInfo.uptime)} +
+ {/if} + +
+ +
+ Refresh + +
+ + + + + + + + +
+
+ + {$connected ? 'Live' : 'Offline'} + +
+
+
+ {/if}
diff --git a/frontend/src/lib/components/KeyboardHelp.svelte b/frontend/src/lib/components/KeyboardHelp.svelte new file mode 100644 index 0000000..50ee9f0 --- /dev/null +++ b/frontend/src/lib/components/KeyboardHelp.svelte @@ -0,0 +1,56 @@ + + + + +
+ +
+ + + +
e.stopPropagation()} + role="dialog" + aria-labelledby="keyboard-help-title" + > +
+

+ Keyboard Shortcuts +

+ +
+ +
+ {#each shortcuts as shortcut} +
+ {shortcut.description} + + {shortcut.key} + +
+ {/each} +
+ +

+ Press ? to toggle this help +

+
+
diff --git a/frontend/src/lib/components/SettingsPanel.svelte b/frontend/src/lib/components/SettingsPanel.svelte new file mode 100644 index 0000000..9b97d51 --- /dev/null +++ b/frontend/src/lib/components/SettingsPanel.svelte @@ -0,0 +1,151 @@ + + + + + + diff --git a/frontend/src/lib/components/cards/AlertsCard.svelte b/frontend/src/lib/components/cards/AlertsCard.svelte new file mode 100644 index 0000000..0cca36b --- /dev/null +++ b/frontend/src/lib/components/cards/AlertsCard.svelte @@ -0,0 +1,194 @@ + + + +
+ + {#if $activeAlerts.length > 0} +
+ {#each $activeAlerts as alert} +
+
+
+
+ + {alert.severity.toUpperCase()} + + {formatTime(alert.triggeredAt)} +
+

{alert.message}

+
+ {#if !alert.acknowledged} + + {:else} + Acked + {/if} +
+
+ {/each} +
+ {:else} +
+ +

No active alerts

+
+ {/if} + + + {#if $alertHistory.length > 0} +
+
Recent History
+
+ {#each $alertHistory.slice(0, 5) as alert} +
+ + {getTypeLabel(alert.type)} - {alert.severity} + + + {alert.resolvedAt ? formatTime(alert.resolvedAt) : 'Active'} + +
+ {/each} +
+
+ {/if} + + + + + + {#if showSettings && localConfig.length > 0} +
+ {#each localConfig as threshold, i} +
+
+ {getTypeLabel(threshold.type)} + +
+ {#if threshold.enabled} +
+
+ + updateLocalThreshold(i, 'warningValue', parseFloat(e.currentTarget.value))} + class="w-full px-2 py-1 rounded bg-white/5 border border-white/10 text-slate-200" + min="0" + max="100" + /> +
+
+ + updateLocalThreshold(i, 'criticalValue', parseFloat(e.currentTarget.value))} + class="w-full px-2 py-1 rounded bg-white/5 border border-white/10 text-slate-200" + min="0" + max="100" + /> +
+
+ {/if} +
+ {/each} + +
+ {/if} +
+
diff --git a/frontend/src/lib/components/cards/CpuCard.svelte b/frontend/src/lib/components/cards/CpuCard.svelte index 3c51afa..05c3396 100644 --- a/frontend/src/lib/components/cards/CpuCard.svelte +++ b/frontend/src/lib/components/cards/CpuCard.svelte @@ -13,21 +13,21 @@ } - + {#if $cpuStats}
-
+
-
- +
+ {$cpuStats.totalUsage.toFixed(1)}% - usage + usage
-
+
@@ -49,7 +49,7 @@
-
+
{#each $cpuStats.cores as core}
diff --git a/frontend/src/lib/components/cards/DiskCard.svelte b/frontend/src/lib/components/cards/DiskCard.svelte index 71515d4..4efd5d9 100644 --- a/frontend/src/lib/components/cards/DiskCard.svelte +++ b/frontend/src/lib/components/cards/DiskCard.svelte @@ -17,7 +17,7 @@ }); - + {#if $diskStats}
diff --git a/frontend/src/lib/components/cards/DockerCard.svelte b/frontend/src/lib/components/cards/DockerCard.svelte new file mode 100644 index 0000000..4ac48e2 --- /dev/null +++ b/frontend/src/lib/components/cards/DockerCard.svelte @@ -0,0 +1,97 @@ + + + + {#if $dockerStats?.available} +
+ +
+
+
Running
+
{$dockerStats.running}
+
+
+
Total
+
{$dockerStats.total}
+
+
+ + + {#if $dockerStats.containers.length > 0} +
+
Containers
+
+ {#each $dockerStats.containers as container} +
+
+ + {container.name} + + + {container.state} + +
+
+ {container.image} +
+ {#if container.state === 'running'} +
+ + CPU: {formatPercent(container.cpuPercent)} + + + Mem: {formatBytes(container.memoryUsage)} + +
+ {/if} +
+ {/each} +
+
+ {:else} +
+ No containers +
+ {/if} +
+ {:else} +
+ 🐳 + Docker not available + + Mount socket: -v /var/run/docker.sock:/var/run/docker.sock:ro + +
+ {/if} +
diff --git a/frontend/src/lib/components/cards/GpuCard.svelte b/frontend/src/lib/components/cards/GpuCard.svelte index 71c6310..96ae19d 100644 --- a/frontend/src/lib/components/cards/GpuCard.svelte +++ b/frontend/src/lib/components/cards/GpuCard.svelte @@ -12,7 +12,7 @@ } - + {#if $gpuStats} {#if $gpuStats.available} {@const vramPercent = $gpuStats.vramTotal > 0 ? ($gpuStats.vramUsed / $gpuStats.vramTotal) * 100 : 0} diff --git a/frontend/src/lib/components/cards/HistoryCard.svelte b/frontend/src/lib/components/cards/HistoryCard.svelte new file mode 100644 index 0000000..104f14a --- /dev/null +++ b/frontend/src/lib/components/cards/HistoryCard.svelte @@ -0,0 +1,85 @@ + + + +
+ +
+
+ CPU + {currentCpu().toFixed(1)}% +
+ +
+ + +
+
+ Memory + {currentMem().toFixed(1)}% +
+ +
+ + +
+
+ Network ↓ + {formatBytes(currentRx())}/s +
+ +
+ + +
+
+ Network ↑ + {formatBytes(currentTx())}/s +
+ +
+
+
diff --git a/frontend/src/lib/components/cards/MemoryCard.svelte b/frontend/src/lib/components/cards/MemoryCard.svelte index 7c459a7..6684199 100644 --- a/frontend/src/lib/components/cards/MemoryCard.svelte +++ b/frontend/src/lib/components/cards/MemoryCard.svelte @@ -6,7 +6,7 @@ import { formatBytes } from '$lib/utils/formatters'; - + {#if $memoryStats} {@const usedPercent = ($memoryStats.used / $memoryStats.total) * 100} {@const swapPercent = $memoryStats.swapTotal > 0 ? ($memoryStats.swapUsed / $memoryStats.swapTotal) * 100 : 0} diff --git a/frontend/src/lib/components/cards/NetworkCard.svelte b/frontend/src/lib/components/cards/NetworkCard.svelte index 5f23147..81ed610 100644 --- a/frontend/src/lib/components/cards/NetworkCard.svelte +++ b/frontend/src/lib/components/cards/NetworkCard.svelte @@ -4,7 +4,7 @@ import { formatBytes } from '$lib/utils/formatters'; - + {#if $networkStats}
{#each $networkStats.interfaces as iface} diff --git a/frontend/src/lib/components/cards/ProcessesCard.svelte b/frontend/src/lib/components/cards/ProcessesCard.svelte index 6302b24..75bf72e 100644 --- a/frontend/src/lib/components/cards/ProcessesCard.svelte +++ b/frontend/src/lib/components/cards/ProcessesCard.svelte @@ -1,17 +1,32 @@ - + {#if $processStats && ($processStats.topByCpu?.length > 0 || $processStats.topByMemory?.length > 0)}
- -
+ +
-
+
+ + {#if searchQuery} + + {/if} +
+
+ {#if searchQuery} + {processes.length} of + {/if} {$processStats.total} total
-
+
{#each processes.slice(0, 10) as proc, i (proc.pid)} -
-
- {i + 1} -
-
-
- {proc.name} +
+
-
-
- {proc.cpuPercent.toFixed(1)}% +
+
+ {proc.name} +
+
PID {proc.pid}
-
- {proc.memoryMb.toFixed(0)} MB +
+
+ {proc.cpuPercent.toFixed(1)}% +
+
+ {proc.memoryMb.toFixed(0)} MB +
-
+
+ {expandedPid === proc.pid ? '▲' : '▼'} +
+ + + + {#if expandedPid === proc.pid} +
+
+
+ State: + + {getStateLabel(proc.state).label} + +
+
+ CPU: + + {proc.cpuPercent.toFixed(2)}% + +
+
+ Memory: + + {proc.memoryMb.toFixed(1)} MB + +
+
+ PID: + {proc.pid} +
+
+ +
+
+
+ CPU Usage + {proc.cpuPercent.toFixed(1)}% +
+
+
+
+
+
+
+ Memory + {proc.memoryMb.toFixed(0)} MB +
+
+
+
+
+
+
+ {/if}
{/each}
diff --git a/frontend/src/lib/components/cards/SystemdCard.svelte b/frontend/src/lib/components/cards/SystemdCard.svelte new file mode 100644 index 0000000..bd1cfff --- /dev/null +++ b/frontend/src/lib/components/cards/SystemdCard.svelte @@ -0,0 +1,101 @@ + + + + {#if $systemdStats?.available} +
+ +
+
+
Active
+
{$systemdStats.active}
+
+
+
Failed
+
+ {$systemdStats.failed} +
+
+
+
Total
+
{$systemdStats.total}
+
+
+ + + {#if failedServices().length > 0} +
+
Failed Services
+
+ {#each failedServices() as service} +
+ + {service.name} + + {service.sub} +
+ {/each} +
+
+ {/if} + + +
+
Running Services
+
+ {#each runningServices().slice(0, 15) as service} +
+ + {service.name} + + {service.sub} +
+ {/each} + {#if runningServices().length > 15} +
+ +{runningServices().length - 15} more +
+ {/if} +
+
+
+ {:else} +
+ ⚙️ + Systemd not available + + Mount D-Bus: -v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro + +
+ {/if} +
diff --git a/frontend/src/lib/components/cards/TemperatureCard.svelte b/frontend/src/lib/components/cards/TemperatureCard.svelte index 6fee3a5..7a729c7 100644 --- a/frontend/src/lib/components/cards/TemperatureCard.svelte +++ b/frontend/src/lib/components/cards/TemperatureCard.svelte @@ -69,7 +69,7 @@ } - + {#if $temperatureStats && $temperatureStats.sensors.length > 0}
diff --git a/frontend/src/lib/components/charts/SparklineChart.svelte b/frontend/src/lib/components/charts/SparklineChart.svelte new file mode 100644 index 0000000..584c672 --- /dev/null +++ b/frontend/src/lib/components/charts/SparklineChart.svelte @@ -0,0 +1,78 @@ + + + + {#if data.length >= 2} + {#if fill} + + {/if} + + {:else} + + {/if} + diff --git a/frontend/src/lib/components/common/Card.svelte b/frontend/src/lib/components/common/Card.svelte index 956aa4c..c09b053 100644 --- a/frontend/src/lib/components/common/Card.svelte +++ b/frontend/src/lib/components/common/Card.svelte @@ -4,10 +4,12 @@ interface Props { title: string; icon?: string; + info?: string; children: Snippet; } - let { title, icon = '', children }: Props = $props(); + let { title, icon = '', info = '', children }: Props = $props(); + let showInfo = $state(false);
@@ -16,6 +18,22 @@ {icon} {/if} {title} + {#if info} + + {/if} + {#if info && showInfo} +
+ {info} +
+ {/if} {@render children()}
diff --git a/frontend/src/lib/stores/alerts.ts b/frontend/src/lib/stores/alerts.ts new file mode 100644 index 0000000..efca2d3 --- /dev/null +++ b/frontend/src/lib/stores/alerts.ts @@ -0,0 +1,87 @@ +import { writable } from 'svelte/store'; +import type { Alert, AlertConfig, AlertsResponse, AlertThreshold } from '$lib/types/metrics'; + +export const activeAlerts = writable([]); +export const alertHistory = writable([]); +export const alertConfig = writable(null); + +const API_BASE = '/api/v1'; + +export async function fetchAlerts(): Promise { + try { + const response = await fetch(`${API_BASE}/alerts`); + if (response.ok) { + const data: AlertsResponse = await response.json(); + activeAlerts.set(data.active || []); + alertHistory.set(data.history || []); + alertConfig.set(data.config); + } + } catch (error) { + console.error('Failed to fetch alerts:', error); + } +} + +export async function updateAlertConfig(config: AlertConfig): Promise { + try { + const response = await fetch(`${API_BASE}/alerts/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + if (response.ok) { + alertConfig.set(config); + return true; + } + } catch (error) { + console.error('Failed to update alert config:', error); + } + return false; +} + +export async function acknowledgeAlert(alertId: string): Promise { + try { + const response = await fetch(`${API_BASE}/alerts/${alertId}/acknowledge`, { + method: 'POST' + }); + if (response.ok) { + activeAlerts.update((alerts) => + alerts.map((a) => (a.id === alertId ? { ...a, acknowledged: true } : a)) + ); + return true; + } + } catch (error) { + console.error('Failed to acknowledge alert:', error); + } + return false; +} + +export function updateThreshold( + type: string, + field: keyof AlertThreshold, + value: number | boolean +): void { + alertConfig.update((config) => { + if (!config) return config; + return { + thresholds: config.thresholds.map((t) => + t.type === type ? { ...t, [field]: value } : t + ) + }; + }); +} + +// Start polling for alerts +let alertsInterval: ReturnType | null = null; + +export function startAlertPolling(intervalMs: number = 5000): void { + if (alertsInterval) return; + fetchAlerts(); + alertsInterval = setInterval(fetchAlerts, intervalMs); +} + +export function stopAlertPolling(): void { + if (alertsInterval) { + clearInterval(alertsInterval); + alertsInterval = null; + } +} diff --git a/frontend/src/lib/stores/keyboard.ts b/frontend/src/lib/stores/keyboard.ts new file mode 100644 index 0000000..dbde9dc --- /dev/null +++ b/frontend/src/lib/stores/keyboard.ts @@ -0,0 +1,76 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; +import { theme } from './theme'; +import { settings } from './settings'; + +export interface ShortcutInfo { + key: string; + description: string; + modifiers?: string[]; +} + +export const shortcuts: ShortcutInfo[] = [ + { key: 't', description: 'Toggle theme' }, + { key: '1', description: 'Set refresh to 1s' }, + { key: '2', description: 'Set refresh to 2s' }, + { key: '5', description: 'Set refresh to 5s' }, + { key: '?', description: 'Show keyboard shortcuts' }, + { key: 'Escape', description: 'Close dialogs' } +]; + +export const showShortcutsHelp = writable(false); + +export function initKeyboardShortcuts() { + if (!browser) return; + + function handleKeydown(e: KeyboardEvent) { + // Ignore if typing in an input + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement + ) { + // Allow Escape to blur inputs + if (e.key === 'Escape') { + (e.target as HTMLElement).blur(); + } + return; + } + + // Don't handle if modifier keys are pressed (except Shift for ?) + if (e.ctrlKey || e.altKey || e.metaKey) return; + + switch (e.key) { + case 't': + e.preventDefault(); + theme.toggle(); + break; + case '1': + e.preventDefault(); + settings.setRefreshRate(1); + break; + case '2': + e.preventDefault(); + settings.setRefreshRate(2); + break; + case '5': + e.preventDefault(); + settings.setRefreshRate(5); + break; + case '?': + e.preventDefault(); + showShortcutsHelp.update(v => !v); + break; + case 'Escape': + e.preventDefault(); + showShortcutsHelp.set(false); + break; + } + } + + window.addEventListener('keydown', handleKeydown); + + return () => { + window.removeEventListener('keydown', handleKeydown); + }; +} diff --git a/frontend/src/lib/stores/layout.ts b/frontend/src/lib/stores/layout.ts new file mode 100644 index 0000000..2830d1b --- /dev/null +++ b/frontend/src/lib/stores/layout.ts @@ -0,0 +1,266 @@ +import { writable, derived } from 'svelte/store'; +import { browser } from '$app/environment'; + +export interface CardConfig { + id: string; + component: string; + visible: boolean; +} + +export interface LayoutSection { + id: string; + name: string; + description: string; + gridCols: string; // Tailwind grid class + cards: CardConfig[]; +} + +// Card metadata for display +export const cardMeta: Record = { + cpu: { name: 'CPU', icon: '⚡', description: 'Usage, load averages, per-core stats' }, + memory: { name: 'Memory', icon: '🧠', description: 'RAM and swap usage' }, + gpu: { name: 'GPU', icon: '🎮', description: 'AMD GPU utilization, VRAM, temp' }, + history: { name: 'History', icon: '📈', description: 'Historical metrics charts' }, + disk: { name: 'Disk', icon: '💾', description: 'Disk usage and I/O' }, + network: { name: 'Network', icon: '🌐', description: 'Network interfaces and traffic' }, + temperature: { name: 'Temperature', icon: '🌡️', description: 'System temperatures' }, + docker: { name: 'Docker', icon: '🐳', description: 'Container status' }, + systemd: { name: 'Systemd', icon: '⚙️', description: 'Service status' }, + alerts: { name: 'Alerts', icon: '🔔', description: 'System alerts' }, + processes: { name: 'Processes', icon: '📊', description: 'Top processes' } +}; + +// Default layout configuration +const defaultLayout: LayoutSection[] = [ + { + id: 'primary', + name: 'Primary Metrics', + description: 'Main system overview', + gridCols: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3', + cards: [ + { id: 'cpu', component: 'CpuCard', visible: true }, + { id: 'memory', component: 'MemoryCard', visible: true }, + { id: 'gpu', component: 'GpuCard', visible: true } + ] + }, + { + id: 'history', + name: 'History', + description: 'Historical data', + gridCols: '', + cards: [{ id: 'history', component: 'HistoryCard', visible: true }] + }, + { + id: 'system', + name: 'System Info', + description: 'Storage, network, thermal', + gridCols: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', + cards: [ + { id: 'disk', component: 'DiskCard', visible: true }, + { id: 'network', component: 'NetworkCard', visible: true }, + { id: 'temperature', component: 'TemperatureCard', visible: true } + ] + }, + { + id: 'services', + name: 'Services', + description: 'Containers, services, alerts', + gridCols: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', + cards: [ + { id: 'docker', component: 'DockerCard', visible: true }, + { id: 'systemd', component: 'SystemdCard', visible: true }, + { id: 'alerts', component: 'AlertsCard', visible: true } + ] + }, + { + id: 'processes', + name: 'Processes', + description: 'Running processes', + gridCols: '', + cards: [{ id: 'processes', component: 'ProcessesCard', visible: true }] + } +]; + +const STORAGE_KEY = 'sysmon-layout-v2'; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +function loadLayout(): LayoutSection[] { + if (!browser) return deepClone(defaultLayout); + + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const parsed = JSON.parse(stored); + return mergeLayouts(parsed, defaultLayout); + } + } catch (e) { + console.error('Failed to load layout:', e); + } + return deepClone(defaultLayout); +} + +function mergeLayouts(stored: LayoutSection[], defaults: LayoutSection[]): LayoutSection[] { + const result: LayoutSection[] = []; + const allStoredCardIds = new Set(stored.flatMap((s) => s.cards.map((c) => c.id))); + + for (const defaultSection of defaults) { + const storedSection = stored.find((s) => s.id === defaultSection.id); + if (storedSection) { + // Merge section properties from defaults + const mergedSection: LayoutSection = { + ...defaultSection, + cards: storedSection.cards + }; + // Add any new cards that aren't anywhere in stored layout + const newCards = defaultSection.cards.filter((c) => !allStoredCardIds.has(c.id)); + mergedSection.cards = [...mergedSection.cards, ...newCards]; + result.push(mergedSection); + } else { + result.push(deepClone(defaultSection)); + } + } + + return result; +} + +function saveLayout(sections: LayoutSection[]) { + if (browser) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(sections)); + } +} + +function createLayoutStore() { + const { subscribe, set, update } = writable(loadLayout()); + + return { + subscribe, + + // Move card within same section + reorderCard: (sectionId: string, fromIndex: number, toIndex: number) => { + update((sections) => { + const newSections = deepClone(sections); + const section = newSections.find((s) => s.id === sectionId); + if (!section) return sections; + + const [card] = section.cards.splice(fromIndex, 1); + if (!card) return sections; + + section.cards.splice(toIndex, 0, card); + saveLayout(newSections); + return newSections; + }); + }, + + // Move card between sections + moveCard: ( + fromSectionId: string, + toSectionId: string, + fromIndex: number, + toIndex: number + ) => { + update((sections) => { + const newSections = deepClone(sections); + const fromSection = newSections.find((s) => s.id === fromSectionId); + const toSection = newSections.find((s) => s.id === toSectionId); + + if (!fromSection || !toSection) return sections; + + const [card] = fromSection.cards.splice(fromIndex, 1); + if (!card) return sections; + + toSection.cards.splice(toIndex, 0, card); + saveLayout(newSections); + return newSections; + }); + }, + + // Move card to a different section (append at end) + moveCardToSection: (cardId: string, toSectionId: string) => { + update((sections) => { + const newSections = deepClone(sections); + + // Find and remove card from current section + let card: CardConfig | undefined; + for (const section of newSections) { + const idx = section.cards.findIndex((c) => c.id === cardId); + if (idx !== -1) { + [card] = section.cards.splice(idx, 1); + break; + } + } + + if (!card) return sections; + + // Add to target section + const toSection = newSections.find((s) => s.id === toSectionId); + if (!toSection) return sections; + + toSection.cards.push(card); + saveLayout(newSections); + return newSections; + }); + }, + + toggleVisibility: (cardId: string) => { + update((sections) => { + const newSections = deepClone(sections); + for (const section of newSections) { + const card = section.cards.find((c) => c.id === cardId); + if (card) { + card.visible = !card.visible; + break; + } + } + saveLayout(newSections); + return newSections; + }); + }, + + setVisibility: (cardId: string, visible: boolean) => { + update((sections) => { + const newSections = deepClone(sections); + for (const section of newSections) { + const card = section.cards.find((c) => c.id === cardId); + if (card) { + card.visible = visible; + break; + } + } + saveLayout(newSections); + return newSections; + }); + }, + + reset: () => { + const fresh = deepClone(defaultLayout); + if (browser) { + localStorage.removeItem(STORAGE_KEY); + } + set(fresh); + } + }; +} + +export const layout = createLayoutStore(); + +// Edit mode toggle +export const editMode = writable(false); + +// Settings panel visibility +export const showSettings = writable(false); + +// Derived store for hidden cards (for easy display in editor) +export const hiddenCards = derived(layout, ($layout) => { + const hidden: CardConfig[] = []; + for (const section of $layout) { + for (const card of section.cards) { + if (!card.visible) { + hidden.push(card); + } + } + } + return hidden; +}); diff --git a/frontend/src/lib/stores/metrics.ts b/frontend/src/lib/stores/metrics.ts index bf9fc69..862aae1 100644 --- a/frontend/src/lib/stores/metrics.ts +++ b/frontend/src/lib/stores/metrics.ts @@ -1,5 +1,5 @@ import { writable, derived } from 'svelte/store'; -import type { AllMetrics } from '$lib/types/metrics'; +import type { AllMetrics, HistoryData } from '$lib/types/metrics'; // Main metrics store export const metrics = writable(null); @@ -7,6 +7,9 @@ export const metrics = writable(null); // Connection status export const connected = writable(false); +// Historical data from backend +export const historyData = writable(null); + // Derived stores for individual sections export const cpuStats = derived(metrics, ($m) => $m?.cpu ?? null); export const memoryStats = derived(metrics, ($m) => $m?.memory ?? null); @@ -16,6 +19,8 @@ export const processStats = derived(metrics, ($m) => $m?.processes ?? null); export const temperatureStats = derived(metrics, ($m) => $m?.temperature ?? null); export const gpuStats = derived(metrics, ($m) => $m?.gpu ?? null); export const systemInfo = derived(metrics, ($m) => $m?.system ?? null); +export const dockerStats = derived(metrics, ($m) => $m?.docker ?? null); +export const systemdStats = derived(metrics, ($m) => $m?.systemd ?? null); // Historical data for sparklines const HISTORY_SIZE = 60; diff --git a/frontend/src/lib/stores/theme.ts b/frontend/src/lib/stores/theme.ts new file mode 100644 index 0000000..d3200fb --- /dev/null +++ b/frontend/src/lib/stores/theme.ts @@ -0,0 +1,62 @@ +import { writable } from 'svelte/store'; +import { browser } from '$app/environment'; + +type Theme = 'dark' | 'light'; + +function createThemeStore() { + // Get initial theme from localStorage or system preference + const getInitialTheme = (): Theme => { + if (!browser) return 'dark'; + + const stored = localStorage.getItem('theme') as Theme | null; + if (stored === 'light' || stored === 'dark') return stored; + + // Check system preference + if (window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + + return 'dark'; + }; + + const { subscribe, set, update } = writable(getInitialTheme()); + + return { + subscribe, + toggle: () => { + update(current => { + const next = current === 'dark' ? 'light' : 'dark'; + if (browser) { + localStorage.setItem('theme', next); + updateDocumentClass(next); + } + return next; + }); + }, + set: (theme: Theme) => { + if (browser) { + localStorage.setItem('theme', theme); + updateDocumentClass(theme); + } + set(theme); + } + }; +} + +function updateDocumentClass(theme: Theme) { + if (theme === 'light') { + document.documentElement.classList.add('light'); + } else { + document.documentElement.classList.remove('light'); + } +} + +export const theme = createThemeStore(); + +// Initialize theme class on load +if (browser) { + const stored = localStorage.getItem('theme') as Theme | null; + if (stored === 'light') { + document.documentElement.classList.add('light'); + } +} diff --git a/frontend/src/lib/types/metrics.ts b/frontend/src/lib/types/metrics.ts index 5d88477..b893ea2 100644 --- a/frontend/src/lib/types/metrics.ts +++ b/frontend/src/lib/types/metrics.ts @@ -107,6 +107,56 @@ export interface AMDGPUStats { clockMemory: number; } +export interface ContainerStats { + id: string; + name: string; + image: string; + state: string; + status: string; + cpuPercent: number; + memoryUsage: number; + memoryLimit: number; + memoryPercent: number; +} + +export interface DockerStats { + available: boolean; + total: number; + running: number; + containers: ContainerStats[]; +} + +export interface ServiceStatus { + name: string; + load: string; + active: string; + sub: string; +} + +export interface SystemdStats { + available: boolean; + total: number; + active: number; + inactive: number; + failed: number; + services: ServiceStatus[]; +} + +export interface HistoryDataPoint { + timestamp: string; + value: number; +} + +export interface HistoryData { + cpu: HistoryDataPoint[]; + memory: HistoryDataPoint[]; + gpu: HistoryDataPoint[]; + networkRx: HistoryDataPoint[]; + networkTx: HistoryDataPoint[]; + diskRead: HistoryDataPoint[]; + diskWrite: HistoryDataPoint[]; +} + export interface AllMetrics { timestamp: string; system: SystemInfo; @@ -117,4 +167,40 @@ export interface AllMetrics { processes: ProcessStats; temperature: TemperatureStats; gpu: AMDGPUStats; + docker: DockerStats; + systemd: SystemdStats; +} + +// Alert types +export type AlertType = 'cpu' | 'memory' | 'temperature' | 'disk' | 'gpu'; +export type AlertSeverity = 'warning' | 'critical'; + +export interface AlertThreshold { + type: AlertType; + warningValue: number; + criticalValue: number; + enabled: boolean; + durationSeconds: number; +} + +export interface Alert { + id: string; + type: AlertType; + severity: AlertSeverity; + message: string; + value: number; + threshold: number; + triggeredAt: string; + resolvedAt?: string; + acknowledged: boolean; +} + +export interface AlertConfig { + thresholds: AlertThreshold[]; +} + +export interface AlertsResponse { + active: Alert[]; + history: Alert[]; + config: AlertConfig; } diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 6cfa270..92f6f5e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,30 +1,60 @@ -
+
-
+
{@render children()}
+ + {#if $showShortcutsHelp} + + {/if} + + + {#if $showSettings} + showSettings.set(false)} /> + {/if} + + + {#if $editMode} + + {/if} +
-
-
-
+ {#if $theme === 'light'} +
+
+
+ {:else} +
+
+
+ {/if}
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6a25ec9..c4d8371 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,4 +1,5 @@ System Monitor -
- -
- - - -
+
+ {#each $layout as section (section.id)} + {@const visibleCards = section.cards.filter(c => c.visible)} - -
- - - -
- - - + {#if visibleCards.length > 0} + {#if section.gridCols} + +
+ {#each visibleCards as card (card.id)} + {@const Component = getComponent(card)} + + {/each} +
+ {:else} + + {#each visibleCards as card (card.id)} + {@const Component = getComponent(card)} + + {/each} + {/if} + {/if} + {/each}
diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts new file mode 100644 index 0000000..bc7915d --- /dev/null +++ b/frontend/src/service-worker.ts @@ -0,0 +1,84 @@ +/// +/// +/// +/// + +import { build, files, version } from '$service-worker'; + +const sw = self as unknown as ServiceWorkerGlobalScope; + +// Create unique cache name for this deployment +const CACHE = `cache-${version}`; + +// Assets to cache - build outputs and static files +const ASSETS = [ + ...build, // the app itself + ...files // static files +]; + +sw.addEventListener('install', (event) => { + // Create a new cache and add all files to it + async function addFilesToCache() { + const cache = await caches.open(CACHE); + await cache.addAll(ASSETS); + } + + event.waitUntil(addFilesToCache()); +}); + +sw.addEventListener('activate', (event) => { + // Remove old caches + async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) await caches.delete(key); + } + } + + event.waitUntil(deleteOldCaches()); +}); + +sw.addEventListener('fetch', (event) => { + // Skip non-GET requests and API calls (we want fresh data) + if (event.request.method !== 'GET') return; + + const url = new URL(event.request.url); + + // Don't cache API requests or SSE streams + if (url.pathname.startsWith('/api/')) return; + + event.respondWith( + (async () => { + const cache = await caches.open(CACHE); + + // Try cache first for static assets + if (ASSETS.includes(url.pathname)) { + const cachedResponse = await cache.match(event.request); + if (cachedResponse) return cachedResponse; + } + + // For other requests, try network first + try { + const response = await fetch(event.request); + + // Cache successful responses + if (response.status === 200) { + cache.put(event.request, response.clone()); + } + + return response; + } catch { + // Network failed, try cache + const cachedResponse = await cache.match(event.request); + if (cachedResponse) return cachedResponse; + + // Return offline page for navigation requests + if (event.request.mode === 'navigate') { + const offlineResponse = await cache.match('/'); + if (offlineResponse) return offlineResponse; + } + + throw new Error('No cached response available'); + } + })() + ); +}); diff --git a/frontend/static/icon-192.svg b/frontend/static/icon-192.svg new file mode 100644 index 0000000..7bae1ea --- /dev/null +++ b/frontend/static/icon-192.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/static/icon-512.svg b/frontend/static/icon-512.svg new file mode 100644 index 0000000..40dd3e1 --- /dev/null +++ b/frontend/static/icon-512.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json new file mode 100644 index 0000000..afd15ad --- /dev/null +++ b/frontend/static/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "System Monitor", + "short_name": "SysMon", + "description": "Real-time system monitoring dashboard for Linux", + "start_url": "/", + "display": "standalone", + "background_color": "#0f172a", + "theme_color": "#3b82f6", + "orientation": "any", + "icons": [ + { + "src": "/icon-192.svg", + "sizes": "192x192", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icon-512.svg", + "sizes": "512x512", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["utilities", "productivity"] +} diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 700fe58..2f11389 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -11,7 +11,10 @@ const config = { fallback: 'index.html', precompress: false, strict: true - }) + }), + serviceWorker: { + register: true + } } };