feat: add dashboard customization, alerts, PWA, and mobile support

Dashboard Editor & Layout:
- Full-screen visual editor for reorganizing cards
- Drag-and-drop cards between sections
- Toggle card visibility with persistence to localStorage
- Reset to default layout option

Alerts System:
- Threshold-based alerts for CPU, memory, temperature, disk, GPU
- Alert manager with duration requirements
- AlertsCard component with settings UI
- API endpoints for alerts CRUD

New Collectors:
- Docker container monitoring with parallel stats fetching
- Systemd service status via D-Bus
- Historical metrics storage (1 hour at 1s intervals)

PWA Support:
- Service worker with offline caching
- Web app manifest with SVG icons
- iOS PWA meta tags

Mobile Responsive:
- Collapsible hamburger menu on mobile
- Adaptive grid layouts for all screen sizes
- Touch-friendly hover states
- Safe area insets for notched devices

UI Enhancements:
- Light/dark theme toggle with persistence
- Keyboard shortcuts (T=theme, R=refresh, ?=help)
- Per-process expandable details in ProcessesCard
- Sparkline charts for historical data

Performance Fixes:
- Buffered SSE channels to prevent blocking
- Parallel Docker stats collection with timeout
- D-Bus timeout for systemd collector

Tests:
- Unit tests for CPU, memory, network collectors
- Alert manager tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 05:35:28 +01:00
parent 38a598baaa
commit f4dbc55851
54 changed files with 6889 additions and 143 deletions

View File

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

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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]
}
}

View File

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

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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(),
}
}

View File

@@ -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},
},
}
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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"`
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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;

2593
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}
}

View File

@@ -5,6 +5,18 @@
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>System Monitor</title>
<!-- PWA -->
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<meta name="theme-color" content="#3b82f6" />
<meta name="description" content="Real-time system monitoring dashboard for Linux" />
<!-- iOS PWA support -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="System Monitor" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon-192.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="bg-surface-950 text-white">

View File

@@ -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<typeof setTimeout> | null = null;
let historyInterval: ReturnType<typeof setInterval> | 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;

View File

@@ -0,0 +1,288 @@
<script lang="ts">
import { layout, editMode, cardMeta, hiddenCards, type CardConfig } from '$lib/stores/layout';
import { theme } from '$lib/stores/theme';
// Drag state
let draggedCard = $state<{ cardId: string; sectionId: string; index: number } | null>(null);
let dropTarget = $state<{ sectionId: string; index: number } | null>(null);
let dragOverSection = $state<string | null>(null);
function handleDragStart(e: DragEvent, cardId: string, sectionId: string, index: number) {
draggedCard = { cardId, sectionId, index };
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', JSON.stringify({ cardId, sectionId, index }));
}
// Add drag image
const target = e.target as HTMLElement;
if (target) {
target.style.opacity = '0.5';
}
}
function handleDragEnd(e: DragEvent) {
const target = e.target as HTMLElement;
if (target) {
target.style.opacity = '1';
}
draggedCard = null;
dropTarget = null;
dragOverSection = null;
}
function handleDragOver(e: DragEvent, sectionId: string, index: number) {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
if (draggedCard) {
dropTarget = { sectionId, index };
dragOverSection = sectionId;
}
}
function handleDragLeave(e: DragEvent) {
const target = e.currentTarget as HTMLElement;
const related = e.relatedTarget as HTMLElement | null;
// Only clear if leaving the section entirely
if (!related || !target.contains(related)) {
if (dragOverSection === (target.dataset.sectionId || null)) {
dragOverSection = null;
}
}
}
function handleSectionDragOver(e: DragEvent, sectionId: string) {
e.preventDefault();
if (draggedCard) {
dragOverSection = sectionId;
}
}
function handleDrop(e: DragEvent, sectionId: string, index: number) {
e.preventDefault();
if (!draggedCard) return;
if (draggedCard.sectionId === sectionId) {
// Reorder within same section
if (draggedCard.index !== index) {
layout.reorderCard(sectionId, draggedCard.index, index);
}
} else {
// Move between sections
layout.moveCard(draggedCard.sectionId, sectionId, draggedCard.index, index);
}
draggedCard = null;
dropTarget = null;
dragOverSection = null;
}
function handleSectionDrop(e: DragEvent, sectionId: string) {
e.preventDefault();
if (!draggedCard) return;
const section = $layout.find((s) => s.id === sectionId);
if (!section) return;
if (draggedCard.sectionId === sectionId) {
// Move to end of same section
layout.reorderCard(sectionId, draggedCard.index, section.cards.length - 1);
} else {
// Move to end of different section
layout.moveCard(draggedCard.sectionId, sectionId, draggedCard.index, section.cards.length);
}
draggedCard = null;
dropTarget = null;
dragOverSection = null;
}
function toggleCardVisibility(cardId: string) {
layout.toggleVisibility(cardId);
}
function closeEditor() {
editMode.set(false);
}
function resetLayout() {
if (confirm('Reset dashboard to default layout? This cannot be undone.')) {
layout.reset();
}
}
function getCardInfo(cardId: string) {
return cardMeta[cardId] || { name: cardId, icon: '📦', description: '' };
}
</script>
<!-- Full-screen editor overlay -->
<div class="fixed inset-0 z-50 flex flex-col {$theme === 'light' ? 'bg-slate-100' : 'bg-slate-900'}">
<!-- Header -->
<header class="flex-shrink-0 border-b {$theme === 'light' ? 'bg-white border-slate-200' : 'bg-slate-800 border-slate-700'}">
<div class="container mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</div>
<div>
<h1 class="font-semibold {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
Dashboard Editor
</h1>
<p class="text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">
Drag cards to reorganize • Click eye to hide
</p>
</div>
</div>
<div class="flex items-center gap-2">
<button
onclick={resetLayout}
class="px-3 py-1.5 text-sm rounded-lg transition-colors
{$theme === 'light' ? 'text-slate-600 hover:bg-slate-100' : 'text-slate-400 hover:bg-slate-700'}"
>
Reset
</button>
<button
onclick={closeEditor}
class="px-4 py-1.5 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
>
Done
</button>
</div>
</div>
</header>
<!-- Main content area -->
<div class="flex-1 overflow-auto p-4">
<div class="container mx-auto max-w-6xl space-y-4">
{#each $layout as section (section.id)}
<div
class="rounded-xl border-2 border-dashed transition-all duration-200
{dragOverSection === section.id ? ($theme === 'light' ? 'border-blue-400 bg-blue-50' : 'border-blue-500 bg-blue-500/10') : ($theme === 'light' ? 'border-slate-300 bg-white' : 'border-slate-600 bg-slate-800/50')}"
data-section-id={section.id}
ondragover={(e) => handleSectionDragOver(e, section.id)}
ondragleave={handleDragLeave}
ondrop={(e) => handleSectionDrop(e, section.id)}
role="region"
aria-label={section.name}
>
<!-- Section header -->
<div class="px-4 py-2 border-b {$theme === 'light' ? 'border-slate-200' : 'border-slate-700'}">
<div class="flex items-center justify-between">
<div>
<h2 class="font-medium {$theme === 'light' ? 'text-slate-700' : 'text-slate-200'}">
{section.name}
</h2>
<p class="text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">
{section.description}
</p>
</div>
<span class="text-xs px-2 py-1 rounded-full {$theme === 'light' ? 'bg-slate-100 text-slate-600' : 'bg-slate-700 text-slate-300'}">
{section.cards.filter((c) => c.visible).length} / {section.cards.length} cards
</span>
</div>
</div>
<!-- Cards grid -->
<div class="p-3">
{#if section.cards.length === 0}
<div class="py-8 text-center {$theme === 'light' ? 'text-slate-400' : 'text-slate-500'}">
<p class="text-sm">Drop cards here</p>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-2">
{#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}
<div
class="relative group rounded-lg border transition-all duration-150 cursor-grab active:cursor-grabbing
{isDragging ? 'opacity-40 scale-95' : ''}
{isDropTarget && !isDragging ? 'ring-2 ring-blue-500 ring-offset-2' : ''}
{card.visible
? ($theme === 'light' ? 'bg-white border-slate-200 hover:border-blue-300 hover:shadow-md' : 'bg-slate-700 border-slate-600 hover:border-blue-500 hover:shadow-lg')
: ($theme === 'light' ? 'bg-slate-50 border-slate-200 opacity-50' : 'bg-slate-800 border-slate-700 opacity-50')}"
draggable="true"
role="listitem"
ondragstart={(e) => handleDragStart(e, card.id, section.id, index)}
ondragend={handleDragEnd}
ondragover={(e) => handleDragOver(e, section.id, index)}
ondrop={(e) => handleDrop(e, section.id, index)}
>
<div class="p-3 text-center">
<div class="text-2xl mb-1">{info.icon}</div>
<div class="text-xs font-medium truncate {$theme === 'light' ? 'text-slate-700' : 'text-slate-200'}">
{info.name}
</div>
</div>
<!-- Visibility toggle -->
<button
onclick={(e) => { e.stopPropagation(); toggleCardVisibility(card.id); }}
class="absolute top-1 right-1 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity
{$theme === 'light' ? 'hover:bg-slate-100' : 'hover:bg-slate-600'}"
title={card.visible ? 'Hide card' : 'Show card'}
>
{#if card.visible}
<svg class="w-4 h-4 {$theme === 'light' ? 'text-slate-400' : 'text-slate-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{:else}
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{/if}
</button>
<!-- Drag handle indicator -->
<div class="absolute bottom-1 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-4 h-4 {$theme === 'light' ? 'text-slate-300' : 'text-slate-500'}" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 2zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 7 14zm6-8a2 2 0 1 0-.001-4.001A2 2 0 0 0 13 6zm0 2a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 8zm0 6a2 2 0 1 0 .001 4.001A2 2 0 0 0 13 14z"/>
</svg>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/each}
</div>
</div>
<!-- Hidden cards sidebar/footer -->
{#if $hiddenCards.length > 0}
<div class="flex-shrink-0 border-t {$theme === 'light' ? 'bg-white border-slate-200' : 'bg-slate-800 border-slate-700'}">
<div class="container mx-auto px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-xs font-medium {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">
Hidden ({$hiddenCards.length}):
</span>
<div class="flex flex-wrap gap-2">
{#each $hiddenCards as card (card.id)}
{@const info = getCardInfo(card.id)}
<button
onclick={() => toggleCardVisibility(card.id)}
class="flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs transition-colors
{$theme === 'light' ? 'bg-slate-100 hover:bg-slate-200 text-slate-600' : 'bg-slate-700 hover:bg-slate-600 text-slate-300'}"
title="Click to show"
>
<span>{info.icon}</span>
<span>{info.name}</span>
<svg class="w-3 h-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>

View File

@@ -1,57 +1,102 @@
<script lang="ts">
import { connected, systemInfo } from '$lib/stores/metrics';
import { settings } from '$lib/stores/settings';
import { theme } from '$lib/stores/theme';
import { showShortcutsHelp } from '$lib/stores/keyboard';
import { showSettings, editMode } from '$lib/stores/layout';
import { formatUptime } from '$lib/utils/formatters';
const refreshRates = [1, 2, 5, 10, 30];
let mobileMenuOpen = $state(false);
</script>
<header class="sticky top-0 z-50 backdrop-blur-xl border-b border-white/5">
<div class="absolute inset-0 bg-gradient-to-r from-slate-900/90 via-slate-800/90 to-slate-900/90"></div>
<div class="relative container mx-auto px-6 py-4">
<header class="sticky top-0 z-50 backdrop-blur-xl border-b {$theme === 'light' ? 'border-black/5' : 'border-white/5'}">
<div class="absolute inset-0 {$theme === 'light' ? 'bg-gradient-to-r from-slate-100/90 via-white/90 to-slate-100/90' : 'bg-gradient-to-r from-slate-900/90 via-slate-800/90 to-slate-900/90'}"></div>
<div class="relative container mx-auto px-4 sm:px-6 py-3 sm:py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-6">
<div class="flex items-center gap-3 sm:gap-6">
<!-- Logo -->
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="flex items-center gap-2 sm:gap-3">
<div class="w-8 h-8 sm:w-10 sm:h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<h1 class="text-xl font-bold bg-gradient-to-r from-white to-slate-300 bg-clip-text text-transparent">
<h1 class="text-lg sm:text-xl font-bold bg-gradient-to-r {$theme === 'light' ? 'from-slate-800 to-slate-600' : 'from-white to-slate-300'} bg-clip-text text-transparent">
System Monitor
</h1>
{#if $systemInfo}
<p class="text-xs text-slate-400">{$systemInfo.hostname}</p>
<p class="text-[10px] sm:text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">{$systemInfo.hostname}</p>
{/if}
</div>
</div>
<!-- System info badges -->
<!-- System info badges - hidden on mobile -->
{#if $systemInfo}
<div class="hidden md:flex items-center gap-2">
<div class="hidden lg:flex items-center gap-2">
<span class="metric-badge">{$systemInfo.kernel}</span>
<span class="metric-badge">Up: {formatUptime($systemInfo.uptime)}</span>
</div>
{/if}
</div>
<div class="flex items-center gap-4">
<!-- Desktop controls -->
<div class="hidden sm:flex items-center gap-3 sm:gap-4">
<!-- Refresh rate -->
<div class="flex items-center gap-2">
<span class="text-xs text-slate-400 hidden sm:inline">Refresh</span>
<span class="text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'} hidden md:inline">Refresh</span>
<select
class="bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500/50 cursor-pointer"
class="{$theme === 'light' ? 'bg-black/5 border-black/10 text-slate-800' : 'bg-white/5 border-white/10 text-white'} border rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-blue-500/50 cursor-pointer"
value={$settings.refreshRate}
onchange={(e) => settings.setRefreshRate(parseInt(e.currentTarget.value))}
>
{#each refreshRates as rate}
<option value={rate} class="bg-slate-800">{rate}s</option>
<option value={rate} class="{$theme === 'light' ? 'bg-white' : 'bg-slate-800'}">{rate}s</option>
{/each}
</select>
</div>
<!-- Theme toggle -->
<button
onclick={() => theme.toggle()}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10' : 'bg-white/5 hover:bg-white/10'} p-2 rounded-lg transition-colors"
title="Toggle theme (T)"
>
{#if $theme === 'light'}
<svg class="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
{/if}
</button>
<!-- Edit dashboard layout -->
<button
onclick={() => editMode.set(true)}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10 text-slate-500' : 'bg-white/5 hover:bg-white/10 text-slate-400'} p-2 rounded-lg transition-colors"
title="Edit dashboard layout"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</button>
<!-- Keyboard shortcuts help -->
<button
onclick={() => showShortcutsHelp.set(true)}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10 text-slate-500' : 'bg-white/5 hover:bg-white/10 text-slate-400'} p-2 rounded-lg transition-colors hidden md:block"
title="Keyboard shortcuts (?)"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<!-- Connection status -->
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg {$connected ? 'bg-emerald-500/10' : 'bg-red-500/10'}">
<div
@@ -62,6 +107,94 @@
</span>
</div>
</div>
<!-- Mobile controls -->
<div class="flex sm:hidden items-center gap-2">
<!-- Connection status (compact) -->
<div class="w-2.5 h-2.5 rounded-full {$connected ? 'bg-emerald-400 connected-indicator' : 'bg-red-400'}"></div>
<!-- Mobile menu button -->
<button
onclick={() => mobileMenuOpen = !mobileMenuOpen}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10' : 'bg-white/5 hover:bg-white/10'} p-2 rounded-lg transition-colors"
aria-label="Toggle menu"
>
{#if mobileMenuOpen}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</button>
</div>
</div>
<!-- Mobile menu dropdown -->
{#if mobileMenuOpen}
<div class="sm:hidden mt-3 pt-3 border-t {$theme === 'light' ? 'border-black/10' : 'border-white/10'}">
<!-- System info -->
{#if $systemInfo}
<div class="flex flex-wrap gap-2 mb-3">
<span class="metric-badge text-xs">{$systemInfo.kernel}</span>
<span class="metric-badge text-xs">Up: {formatUptime($systemInfo.uptime)}</span>
</div>
{/if}
<div class="flex items-center justify-between gap-2">
<!-- Refresh rate -->
<div class="flex items-center gap-2 flex-1">
<span class="text-xs {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">Refresh</span>
<select
class="{$theme === 'light' ? 'bg-black/5 border-black/10 text-slate-800' : 'bg-white/5 border-white/10 text-white'} border rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-blue-500/50 cursor-pointer flex-1"
value={$settings.refreshRate}
onchange={(e) => settings.setRefreshRate(parseInt(e.currentTarget.value))}
>
{#each refreshRates as rate}
<option value={rate} class="{$theme === 'light' ? 'bg-white' : 'bg-slate-800'}">{rate}s</option>
{/each}
</select>
</div>
<!-- Edit layout -->
<button
onclick={() => { editMode.set(true); mobileMenuOpen = false; }}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10 text-slate-500' : 'bg-white/5 hover:bg-white/10 text-slate-400'} p-2.5 rounded-lg transition-colors"
title="Edit layout"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</button>
<!-- Theme toggle -->
<button
onclick={() => theme.toggle()}
class="{$theme === 'light' ? 'bg-black/5 hover:bg-black/10' : 'bg-white/5 hover:bg-white/10'} p-2.5 rounded-lg transition-colors"
title="Toggle theme"
>
{#if $theme === 'light'}
<svg class="w-5 h-5 text-amber-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-5 h-5 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
{/if}
</button>
<!-- Connection status badge -->
<div class="flex items-center gap-2 px-3 py-2 rounded-lg {$connected ? 'bg-emerald-500/10' : 'bg-red-500/10'}">
<div class="w-2 h-2 rounded-full {$connected ? 'bg-emerald-400' : 'bg-red-400'}"></div>
<span class="text-sm {$connected ? 'text-emerald-400' : 'text-red-400'}">
{$connected ? 'Live' : 'Offline'}
</span>
</div>
</div>
</div>
{/if}
</div>
</header>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import { shortcuts, showShortcutsHelp } from '$lib/stores/keyboard';
import { theme } from '$lib/stores/theme';
function close() {
showShortcutsHelp.set(false);
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
onclick={close}
>
<!-- Backdrop -->
<div class="absolute inset-0 {$theme === 'light' ? 'bg-black/20' : 'bg-black/50'} backdrop-blur-sm"></div>
<!-- Modal -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="relative {$theme === 'light' ? 'bg-white border-slate-200' : 'bg-slate-800 border-slate-700'} border rounded-2xl shadow-2xl max-w-md w-full p-6"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-labelledby="keyboard-help-title"
>
<div class="flex items-center justify-between mb-4">
<h2 id="keyboard-help-title" class="text-lg font-semibold {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
Keyboard Shortcuts
</h2>
<button
onclick={close}
class="{$theme === 'light' ? 'text-slate-400 hover:text-slate-600' : 'text-slate-400 hover:text-white'} transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="space-y-2">
{#each shortcuts as shortcut}
<div class="flex items-center justify-between py-2 {$theme === 'light' ? 'border-b border-slate-100' : 'border-b border-slate-700'} last:border-0">
<span class="{$theme === 'light' ? 'text-slate-600' : 'text-slate-300'}">{shortcut.description}</span>
<kbd class="{$theme === 'light' ? 'bg-slate-100 text-slate-700 border-slate-200' : 'bg-slate-700 text-slate-200 border-slate-600'} px-2 py-1 rounded border text-sm font-mono">
{shortcut.key}
</kbd>
</div>
{/each}
</div>
<p class="mt-4 text-xs {$theme === 'light' ? 'text-slate-400' : 'text-slate-500'}">
Press <kbd class="px-1 py-0.5 rounded {$theme === 'light' ? 'bg-slate-100' : 'bg-slate-700'} font-mono">?</kbd> to toggle this help
</p>
</div>
</div>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { layout, cardMeta, editMode } from '$lib/stores/layout';
import { theme } from '$lib/stores/theme';
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
// Get all cards with their current visibility
const allCards = $derived.by(() => {
const cards: Array<{ id: string; visible: boolean; sectionId: string }> = [];
for (const section of $layout) {
for (const card of section.cards) {
cards.push({ id: card.id, visible: card.visible, sectionId: section.id });
}
}
return cards;
});
function handleToggle(cardId: string) {
layout.toggleVisibility(cardId);
}
function handleReset() {
layout.reset();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
aria-labelledby="settings-title"
>
<!-- Panel -->
<div
class="relative w-full max-w-lg max-h-[80vh] overflow-hidden rounded-2xl shadow-2xl
{$theme === 'light' ? 'bg-white border border-slate-200' : 'bg-slate-800 border border-slate-700'}"
>
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b {$theme === 'light' ? 'border-slate-200' : 'border-slate-700'}">
<h2 id="settings-title" class="text-lg font-semibold {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
Dashboard Settings
</h2>
<button
onclick={onClose}
class="p-1 rounded-lg transition-colors {$theme === 'light' ? 'text-slate-400 hover:text-slate-600 hover:bg-slate-100' : 'text-slate-400 hover:text-white hover:bg-slate-700'}"
aria-label="Close settings"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="p-4 overflow-y-auto max-h-[60vh]">
<!-- Open editor button -->
<button
onclick={() => { onClose(); editMode.set(true); }}
class="w-full mb-4 p-3 rounded-xl border-2 border-dashed flex items-center gap-3 transition-colors
{$theme === 'light' ? 'border-blue-200 bg-blue-50 hover:border-blue-300 hover:bg-blue-100' : 'border-blue-500/30 bg-blue-500/10 hover:border-blue-500/50 hover:bg-blue-500/20'}"
>
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
</div>
<div class="text-left">
<div class="font-medium {$theme === 'light' ? 'text-blue-700' : 'text-blue-300'}">
Open Dashboard Editor
</div>
<div class="text-xs {$theme === 'light' ? 'text-blue-600' : 'text-blue-400'}">
Drag cards between sections, reorganize layout
</div>
</div>
<svg class="w-5 h-5 ml-auto {$theme === 'light' ? 'text-blue-400' : 'text-blue-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<p class="text-sm mb-3 {$theme === 'light' ? 'text-slate-600' : 'text-slate-400'}">
Quick toggle card visibility:
</p>
<!-- Card toggles -->
<div class="space-y-2">
{#each allCards as card (card.id)}
{@const info = cardMeta[card.id] || { name: card.id, icon: '📦', description: '' }}
<label
class="flex items-center gap-3 p-3 rounded-xl cursor-pointer transition-colors
{$theme === 'light' ? 'hover:bg-slate-50' : 'hover:bg-slate-700/50'}
{card.visible ? '' : 'opacity-60'}"
>
<input
type="checkbox"
checked={card.visible}
onchange={() => handleToggle(card.id)}
class="w-5 h-5 rounded border-2 cursor-pointer accent-blue-500
{$theme === 'light' ? 'border-slate-300' : 'border-slate-600'}"
/>
<span class="text-xl">{info.icon}</span>
<div class="flex-1 min-w-0">
<div class="font-medium {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
{info.name}
</div>
<div class="text-xs truncate {$theme === 'light' ? 'text-slate-500' : 'text-slate-400'}">
{info.description}
</div>
</div>
</label>
{/each}
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between p-4 border-t {$theme === 'light' ? 'border-slate-200 bg-slate-50' : 'border-slate-700 bg-slate-900/50'}">
<button
onclick={handleReset}
class="px-4 py-2 text-sm font-medium rounded-lg transition-colors
{$theme === 'light' ? 'text-slate-600 hover:text-slate-800 hover:bg-slate-200' : 'text-slate-400 hover:text-white hover:bg-slate-700'}"
>
Reset to Default
</button>
<button
onclick={onClose}
class="px-4 py-2 text-sm font-medium rounded-lg bg-blue-500 text-white hover:bg-blue-600 transition-colors"
>
Done
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,194 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import Card from '$lib/components/common/Card.svelte';
import {
activeAlerts,
alertHistory,
alertConfig,
fetchAlerts,
acknowledgeAlert,
updateAlertConfig,
startAlertPolling,
stopAlertPolling
} from '$lib/stores/alerts';
import type { Alert, AlertThreshold } from '$lib/types/metrics';
let showSettings = $state(false);
let localConfig = $state<AlertThreshold[]>([]);
onMount(() => {
startAlertPolling(5000);
});
onDestroy(() => {
stopAlertPolling();
});
// Sync local config when alertConfig changes
$effect(() => {
if ($alertConfig) {
localConfig = JSON.parse(JSON.stringify($alertConfig.thresholds));
}
});
function getSeverityColor(severity: string): string {
return severity === 'critical' ? 'text-red-400' : 'text-yellow-400';
}
function getSeverityBg(severity: string): string {
return severity === 'critical' ? 'bg-red-500/10 border-red-500/30' : 'bg-yellow-500/10 border-yellow-500/30';
}
function formatTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString();
}
function getTypeLabel(type: string): string {
const labels: Record<string, string> = {
cpu: 'CPU',
memory: 'Memory',
temperature: 'Temp',
disk: 'Disk',
gpu: 'GPU'
};
return labels[type] || type;
}
async function handleAcknowledge(alert: Alert) {
await acknowledgeAlert(alert.id);
}
async function saveConfig() {
if (localConfig.length > 0) {
await updateAlertConfig({ thresholds: localConfig });
showSettings = false;
}
}
function updateLocalThreshold(index: number, field: string, value: number | boolean) {
localConfig = localConfig.map((t, i) =>
i === index ? { ...t, [field]: value } : t
);
}
</script>
<Card title="Alerts" icon="🔔" info="System alerts based on configurable thresholds. Monitors CPU, memory, disk, temperature, and GPU utilization. Alerts trigger after sustained threshold breaches.">
<div class="space-y-4">
<!-- Active Alerts -->
{#if $activeAlerts.length > 0}
<div class="space-y-2">
{#each $activeAlerts as alert}
<div class="p-3 rounded-lg border {getSeverityBg(alert.severity)}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium px-2 py-0.5 rounded {alert.severity === 'critical' ? 'bg-red-500/20 text-red-400' : 'bg-yellow-500/20 text-yellow-400'}">
{alert.severity.toUpperCase()}
</span>
<span class="text-xs text-slate-400">{formatTime(alert.triggeredAt)}</span>
</div>
<p class="text-sm text-slate-200 truncate">{alert.message}</p>
</div>
{#if !alert.acknowledged}
<button
onclick={() => handleAcknowledge(alert)}
class="text-xs px-2 py-1 rounded bg-white/5 hover:bg-white/10 text-slate-400 hover:text-slate-200 transition-colors"
>
Ack
</button>
{:else}
<span class="text-xs text-slate-500">Acked</span>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="text-center py-4">
<span class="text-2xl"></span>
<p class="text-sm text-slate-400 mt-1">No active alerts</p>
</div>
{/if}
<!-- Recent History -->
{#if $alertHistory.length > 0}
<div class="pt-3 border-t border-white/5">
<div class="text-xs text-slate-500 mb-2">Recent History</div>
<div class="space-y-1 max-h-32 overflow-y-auto">
{#each $alertHistory.slice(0, 5) as alert}
<div class="flex items-center justify-between text-xs py-1">
<span class="text-slate-400">
{getTypeLabel(alert.type)} - {alert.severity}
</span>
<span class="text-slate-500">
{alert.resolvedAt ? formatTime(alert.resolvedAt) : 'Active'}
</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Settings Toggle -->
<button
onclick={() => showSettings = !showSettings}
class="w-full text-xs text-slate-400 hover:text-slate-200 transition-colors pt-2"
>
{showSettings ? '▲ Hide Settings' : '▼ Configure Thresholds'}
</button>
<!-- Settings Panel -->
{#if showSettings && localConfig.length > 0}
<div class="space-y-3 pt-2 border-t border-white/5">
{#each localConfig as threshold, i}
<div class="bg-white/[0.02] rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-300">{getTypeLabel(threshold.type)}</span>
<label class="flex items-center gap-2">
<input
type="checkbox"
checked={threshold.enabled}
onchange={(e) => updateLocalThreshold(i, 'enabled', e.currentTarget.checked)}
class="w-4 h-4 rounded bg-white/10 border-white/20"
/>
<span class="text-xs text-slate-400">Enabled</span>
</label>
</div>
{#if threshold.enabled}
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<label class="text-slate-500 block mb-1">Warning (%)</label>
<input
type="number"
value={threshold.warningValue}
onchange={(e) => 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"
/>
</div>
<div>
<label class="text-slate-500 block mb-1">Critical (%)</label>
<input
type="number"
value={threshold.criticalValue}
onchange={(e) => 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"
/>
</div>
</div>
{/if}
</div>
{/each}
<button
onclick={saveConfig}
class="w-full py-2 rounded-lg bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 transition-colors text-sm font-medium"
>
Save Configuration
</button>
</div>
{/if}
</div>
</Card>

View File

@@ -13,21 +13,21 @@
}
</script>
<Card title="CPU" icon="⚡">
<Card title="CPU" icon="⚡" info="Shows total CPU usage, load averages (1/5/15 min), and per-core utilization. Colors indicate load: green (<40%), blue (40-70%), yellow (70-90%), red (>90%). Data from /proc/stat and /proc/cpuinfo.">
{#if $cpuStats}
<div class="space-y-5">
<!-- Total usage with large display -->
<div class="flex items-center gap-6">
<div class="flex items-center gap-4 sm:gap-6">
<div class="flex-1">
<div class="flex items-end gap-3 mb-2">
<span class="text-4xl font-bold bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">
<div class="flex items-end gap-2 sm:gap-3 mb-2">
<span class="text-3xl sm:text-4xl font-bold bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">
{$cpuStats.totalUsage.toFixed(1)}%
</span>
<span class="text-slate-400 text-sm mb-1">usage</span>
<span class="text-slate-400 text-xs sm:text-sm mb-1">usage</span>
</div>
<ProgressBar value={$cpuStats.totalUsage} color="auto" showLabel={false} size="lg" />
</div>
<div class="w-32 h-16">
<div class="w-24 h-12 sm:w-32 sm:h-16">
<SparkLine data={$cpuHistory} width={128} height={64} color="#3b82f6" />
</div>
</div>
@@ -49,7 +49,7 @@
</div>
<!-- Per-core usage grid -->
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2">
<div class="grid grid-cols-4 min-[480px]:grid-cols-6 sm:grid-cols-8 gap-1.5 sm:gap-2">
{#each $cpuStats.cores as core}
<div class="text-center group">
<div class="core-bar">

View File

@@ -17,7 +17,7 @@
});
</script>
<Card title="Disk" icon="💾">
<Card title="Disk" icon="💾" info="Filesystem usage and I/O activity. Shows mounted partitions (excluding virtual filesystems) and cumulative read/write bytes per device. Data from /proc/mounts and /proc/diskstats.">
{#if $diskStats}
<div class="space-y-4">
<!-- Show unique mounts by device -->

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import Card from '$lib/components/common/Card.svelte';
import { dockerStats } from '$lib/stores/metrics';
import { formatBytes, formatPercent } from '$lib/utils/formatters';
function getStateColor(state: string): string {
switch (state) {
case 'running':
return 'text-emerald-400';
case 'paused':
return 'text-amber-400';
case 'exited':
case 'dead':
return 'text-red-400';
default:
return 'text-slate-400';
}
}
function getStateBadgeClass(state: string): string {
switch (state) {
case 'running':
return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'paused':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'exited':
case 'dead':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
}
}
</script>
<Card title="Docker" icon="🐳" info="Docker container status and resource usage. Shows running/total containers with CPU and memory stats per container. Requires Docker socket access.">
{#if $dockerStats?.available}
<div class="space-y-3">
<!-- Summary stats -->
<div class="grid grid-cols-2 gap-2">
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Running</div>
<div class="text-xl font-bold text-emerald-400">{$dockerStats.running}</div>
</div>
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Total</div>
<div class="text-xl font-bold text-slate-300">{$dockerStats.total}</div>
</div>
</div>
<!-- Container list -->
{#if $dockerStats.containers.length > 0}
<div class="pt-2 border-t border-white/5">
<div class="stat-label mb-2">Containers</div>
<div class="space-y-1 max-h-48 overflow-y-auto">
{#each $dockerStats.containers as container}
<div class="py-2 px-2 rounded hover:bg-white/[0.02] transition-colors">
<div class="flex justify-between items-start mb-1">
<span class="font-medium text-sm text-slate-200 truncate max-w-[60%]" title={container.name}>
{container.name}
</span>
<span class="text-xs px-2 py-0.5 rounded-full border {getStateBadgeClass(container.state)}">
{container.state}
</span>
</div>
<div class="text-xs text-slate-500 truncate mb-1" title={container.image}>
{container.image}
</div>
{#if container.state === 'running'}
<div class="flex gap-3 text-xs">
<span class="text-slate-400">
CPU: <span class="font-mono text-blue-400">{formatPercent(container.cpuPercent)}</span>
</span>
<span class="text-slate-400">
Mem: <span class="font-mono text-purple-400">{formatBytes(container.memoryUsage)}</span>
</span>
</div>
{/if}
</div>
{/each}
</div>
</div>
{:else}
<div class="text-center text-slate-500 text-sm py-4">
No containers
</div>
{/if}
</div>
{:else}
<div class="h-32 flex flex-col items-center justify-center text-slate-400">
<span class="text-3xl mb-2">🐳</span>
<span class="text-sm">Docker not available</span>
<span class="text-xs text-slate-500 mt-1 text-center px-4">
Mount socket: <code class="bg-white/10 px-1 rounded text-xs">-v /var/run/docker.sock:/var/run/docker.sock:ro</code>
</span>
</div>
{/if}
</Card>

View File

@@ -12,7 +12,7 @@
}
</script>
<Card title="GPU" icon="🎮">
<Card title="GPU" icon="🎮" info="AMD GPU metrics via sysfs. Shows utilization, VRAM usage, temperature, power draw, fan speed, and clock frequencies. Requires AMD GPU with amdgpu driver.">
{#if $gpuStats}
{#if $gpuStats.available}
{@const vramPercent = $gpuStats.vramTotal > 0 ? ($gpuStats.vramUsed / $gpuStats.vramTotal) * 100 : 0}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import Card from '$lib/components/common/Card.svelte';
import SparklineChart from '$lib/components/charts/SparklineChart.svelte';
import { historyData } from '$lib/stores/metrics';
import { formatBytes } from '$lib/utils/formatters';
import type { HistoryDataPoint } from '$lib/types/metrics';
// Extract values from history points (now an array directly)
function getValues(points: HistoryDataPoint[] | undefined): number[] {
return points?.map(p => p.value) ?? [];
}
// Get last N minutes of data
function getRecentValues(points: HistoryDataPoint[] | undefined, minutes: number = 10): number[] {
const vals = getValues(points);
const count = Math.min(vals.length, minutes * 12); // Assuming 5s intervals
return vals.slice(-count);
}
const cpuValues = $derived(() => getRecentValues($historyData?.cpu));
const memoryValues = $derived(() => getRecentValues($historyData?.memory));
const gpuValues = $derived(() => getRecentValues($historyData?.gpu));
const networkRxValues = $derived(() => getRecentValues($historyData?.networkRx));
const networkTxValues = $derived(() => getRecentValues($historyData?.networkTx));
const currentCpu = $derived(() => {
const vals = cpuValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
const currentMem = $derived(() => {
const vals = memoryValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
const currentRx = $derived(() => {
const vals = networkRxValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
const currentTx = $derived(() => {
const vals = networkTxValues();
return vals.length > 0 ? vals[vals.length - 1] : 0;
});
</script>
<Card title="History" icon="📈" info="Historical metrics over the last 10 minutes. Data is stored server-side (up to 1 hour at 1s intervals). Charts update every 30 seconds.">
<div class="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<!-- CPU History -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">CPU</span>
<span class="text-sm font-mono text-blue-400">{currentCpu().toFixed(1)}%</span>
</div>
<SparklineChart data={cpuValues()} color="#60a5fa" maxValue={100} />
</div>
<!-- Memory History -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">Memory</span>
<span class="text-sm font-mono text-purple-400">{currentMem().toFixed(1)}%</span>
</div>
<SparklineChart data={memoryValues()} color="#a78bfa" maxValue={100} />
</div>
<!-- Network RX -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">Network ↓</span>
<span class="text-sm font-mono text-emerald-400">{formatBytes(currentRx())}/s</span>
</div>
<SparklineChart data={networkRxValues()} color="#34d399" />
</div>
<!-- Network TX -->
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="flex justify-between items-center mb-2">
<span class="text-xs uppercase tracking-wider text-slate-500">Network ↑</span>
<span class="text-sm font-mono text-amber-400">{formatBytes(currentTx())}/s</span>
</div>
<SparklineChart data={networkTxValues()} color="#fbbf24" />
</div>
</div>
</Card>

View File

@@ -6,7 +6,7 @@
import { formatBytes } from '$lib/utils/formatters';
</script>
<Card title="Memory" icon="🧠">
<Card title="Memory" icon="🧠" info="RAM and swap usage. 'Used' excludes buffers/cache (actual application memory). 'Cached' is memory used for disk cache that can be reclaimed. Data from /proc/meminfo.">
{#if $memoryStats}
{@const usedPercent = ($memoryStats.used / $memoryStats.total) * 100}
{@const swapPercent = $memoryStats.swapTotal > 0 ? ($memoryStats.swapUsed / $memoryStats.swapTotal) * 100 : 0}

View File

@@ -4,7 +4,7 @@
import { formatBytes } from '$lib/utils/formatters';
</script>
<Card title="Network" icon="🌐">
<Card title="Network" icon="🌐" info="Network interface statistics. Shows RX/TX bytes and packets for each interface, plus total TCP connections. Data from /proc/net/dev and /proc/net/tcp.">
{#if $networkStats}
<div class="space-y-4">
{#each $networkStats.interfaces as iface}

View File

@@ -1,17 +1,32 @@
<script lang="ts">
import Card from '$lib/components/common/Card.svelte';
import { processStats } from '$lib/stores/metrics';
import type { ProcessInfo } from '$lib/types/metrics';
let view = $state<'cpu' | 'memory'>('cpu');
let searchQuery = $state('');
let expandedPid = $state<number | null>(null);
// Use $derived with explicit dependency on view
const processes = $derived.by(() => {
// Get all processes based on view
const allProcesses = $derived.by(() => {
if (view === 'cpu') {
return $processStats?.topByCpu ?? [];
}
return $processStats?.topByMemory ?? [];
});
// Filter processes by search query
const processes = $derived.by(() => {
if (!searchQuery.trim()) {
return allProcesses;
}
const query = searchQuery.toLowerCase();
return allProcesses.filter(p =>
p.name.toLowerCase().includes(query) ||
p.pid.toString().includes(query)
);
});
function getCpuColor(percent: number): string {
if (percent > 50) return 'text-red-400';
if (percent > 20) return 'text-amber-400';
@@ -24,13 +39,33 @@
if (mb > 500) return 'text-blue-400';
return 'text-slate-400';
}
function getStateLabel(state: string): { label: string; color: string } {
const states: Record<string, { label: string; color: string }> = {
R: { label: 'Running', color: 'text-emerald-400' },
S: { label: 'Sleeping', color: 'text-blue-400' },
D: { label: 'Disk Sleep', color: 'text-amber-400' },
Z: { label: 'Zombie', color: 'text-red-400' },
T: { label: 'Stopped', color: 'text-slate-400' },
I: { label: 'Idle', color: 'text-slate-500' }
};
return states[state] || { label: state, color: 'text-slate-400' };
}
function clearSearch() {
searchQuery = '';
}
function toggleExpand(pid: number) {
expandedPid = expandedPid === pid ? null : pid;
}
</script>
<Card title="Processes" icon="📊">
<Card title="Processes" icon="📊" info="Top 10 processes by CPU or memory usage. Use the search box to filter by name or PID. Data from /proc/[pid]/stat. CPU% is relative to a single core.">
{#if $processStats && ($processStats.topByCpu?.length > 0 || $processStats.topByMemory?.length > 0)}
<div class="space-y-3">
<!-- Toggle and count -->
<div class="flex items-center justify-between">
<!-- Toggle, search and count -->
<div class="flex flex-wrap items-center gap-3">
<div class="flex rounded-lg bg-white/5 p-0.5">
<button
class="px-3 py-1 text-xs font-medium rounded-md transition-all {view === 'cpu' ? 'bg-blue-500/20 text-blue-400' : 'text-slate-400 hover:text-slate-300'}"
@@ -45,32 +80,116 @@
By Memory
</button>
</div>
<div class="text-xs text-slate-500">
<div class="relative flex-1 min-w-[150px] max-w-xs">
<input
type="text"
placeholder="Search processes..."
bind:value={searchQuery}
class="w-full px-3 py-1.5 text-xs bg-white/5 border border-white/10 rounded-lg text-slate-200 placeholder-slate-500 focus:outline-none focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/20"
/>
{#if searchQuery}
<button
onclick={clearSearch}
class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 text-xs"
>
</button>
{/if}
</div>
<div class="text-xs text-slate-500 ml-auto">
{#if searchQuery}
<span class="text-blue-400">{processes.length}</span> of
{/if}
<span class="text-slate-300 font-medium">{$processStats.total}</span> total
</div>
</div>
<!-- Process grid -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-5 gap-2">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-2">
{#each processes.slice(0, 10) as proc, i (proc.pid)}
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-white/[0.02] hover:bg-white/[0.04] transition-colors border border-white/5">
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-slate-700/50 flex items-center justify-center text-[10px] text-slate-400 font-mono">
{i + 1}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-200 truncate font-medium" title={proc.name}>
{proc.name}
<div class="rounded-lg bg-white/[0.02] border border-white/5 overflow-hidden transition-all {expandedPid === proc.pid ? 'col-span-1 sm:col-span-2 ring-1 ring-blue-500/30' : ''}">
<button
onclick={() => toggleExpand(proc.pid)}
class="w-full flex items-center gap-3 py-2 px-3 hover:bg-white/[0.04] transition-colors text-left"
>
<div class="flex-shrink-0 w-6 h-6 rounded-full bg-slate-700/50 flex items-center justify-center text-[10px] text-slate-400 font-mono">
{i + 1}
</div>
<div class="text-[10px] text-slate-500 font-mono">PID {proc.pid}</div>
</div>
<div class="text-right flex-shrink-0">
<div class="text-sm font-mono font-bold {getCpuColor(proc.cpuPercent)}">
{proc.cpuPercent.toFixed(1)}%
<div class="flex-1 min-w-0">
<div class="text-sm text-slate-200 truncate font-medium" title={proc.name}>
{proc.name}
</div>
<div class="text-[10px] text-slate-500 font-mono">PID {proc.pid}</div>
</div>
<div class="text-[10px] font-mono {getMemColor(proc.memoryMb)}">
{proc.memoryMb.toFixed(0)} MB
<div class="text-right flex-shrink-0">
<div class="text-sm font-mono font-bold {getCpuColor(proc.cpuPercent)}">
{proc.cpuPercent.toFixed(1)}%
</div>
<div class="text-[10px] font-mono {getMemColor(proc.memoryMb)}">
{proc.memoryMb.toFixed(0)} MB
</div>
</div>
</div>
<div class="flex-shrink-0 text-slate-500 text-xs">
{expandedPid === proc.pid ? '▲' : '▼'}
</div>
</button>
<!-- Expanded details -->
{#if expandedPid === proc.pid}
<div class="px-3 pb-3 pt-1 border-t border-white/5 bg-white/[0.01]">
<div class="grid grid-cols-2 gap-3 text-xs">
<div>
<span class="text-slate-500">State:</span>
<span class="{getStateLabel(proc.state).color} ml-1 font-medium">
{getStateLabel(proc.state).label}
</span>
</div>
<div>
<span class="text-slate-500">CPU:</span>
<span class="{getCpuColor(proc.cpuPercent)} ml-1 font-mono">
{proc.cpuPercent.toFixed(2)}%
</span>
</div>
<div>
<span class="text-slate-500">Memory:</span>
<span class="{getMemColor(proc.memoryMb)} ml-1 font-mono">
{proc.memoryMb.toFixed(1)} MB
</span>
</div>
<div>
<span class="text-slate-500">PID:</span>
<span class="text-slate-300 ml-1 font-mono">{proc.pid}</span>
</div>
</div>
<!-- CPU/Memory bars -->
<div class="mt-3 space-y-2">
<div>
<div class="flex justify-between text-[10px] mb-1">
<span class="text-slate-500">CPU Usage</span>
<span class="{getCpuColor(proc.cpuPercent)}">{proc.cpuPercent.toFixed(1)}%</span>
</div>
<div class="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-300"
style="width: {Math.min(proc.cpuPercent, 100)}%"
></div>
</div>
</div>
<div>
<div class="flex justify-between text-[10px] mb-1">
<span class="text-slate-500">Memory</span>
<span class="{getMemColor(proc.memoryMb)}">{proc.memoryMb.toFixed(0)} MB</span>
</div>
<div class="h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-purple-500 to-pink-400 transition-all duration-300"
style="width: {Math.min(proc.memoryMb / 40, 100)}%"
></div>
</div>
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import Card from '$lib/components/common/Card.svelte';
import { systemdStats } from '$lib/stores/metrics';
function getStatusColor(active: string, sub: string): string {
if (active === 'failed') return 'text-red-400';
if (active === 'active' && sub === 'running') return 'text-emerald-400';
if (active === 'active') return 'text-blue-400';
return 'text-slate-500';
}
function getStatusBadgeClass(active: string): string {
switch (active) {
case 'active':
return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'failed':
return 'bg-red-500/20 text-red-400 border-red-500/30';
default:
return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
}
}
const failedServices = $derived(() => {
if (!$systemdStats?.services) return [];
return $systemdStats.services.filter(s => s.active === 'failed');
});
const runningServices = $derived(() => {
if (!$systemdStats?.services) return [];
return $systemdStats.services.filter(s => s.active === 'active' && s.sub === 'running');
});
</script>
<Card title="Services" icon="⚙️" info="Systemd service status via D-Bus. Shows active/failed/total service counts. Failed services are highlighted. Requires D-Bus socket access for containerized deployments.">
{#if $systemdStats?.available}
<div class="space-y-3">
<!-- Summary stats -->
<div class="grid grid-cols-3 gap-2">
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Active</div>
<div class="text-xl font-bold text-emerald-400">{$systemdStats.active}</div>
</div>
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Failed</div>
<div class="text-xl font-bold {$systemdStats.failed > 0 ? 'text-red-400' : 'text-slate-400'}">
{$systemdStats.failed}
</div>
</div>
<div class="bg-white/[0.03] rounded-xl p-3 border border-white/5">
<div class="text-[10px] uppercase tracking-wider text-slate-500 mb-1">Total</div>
<div class="text-xl font-bold text-slate-300">{$systemdStats.total}</div>
</div>
</div>
<!-- Failed services (if any) -->
{#if failedServices().length > 0}
<div class="pt-2 border-t border-red-500/20">
<div class="stat-label mb-2 text-red-400">Failed Services</div>
<div class="space-y-1">
{#each failedServices() as service}
<div class="flex justify-between items-center py-1 px-2 rounded bg-red-500/10 border border-red-500/20">
<span class="text-sm text-red-300 truncate" title={service.name}>
{service.name}
</span>
<span class="text-xs text-red-400">{service.sub}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Running services -->
<div class="pt-2 border-t border-white/5">
<div class="stat-label mb-2">Running Services</div>
<div class="space-y-1 max-h-32 overflow-y-auto">
{#each runningServices().slice(0, 15) as service}
<div class="flex justify-between items-center py-1 px-2 rounded text-sm hover:bg-white/[0.02] transition-colors">
<span class="text-slate-300 truncate" title={service.name}>
{service.name}
</span>
<span class="text-xs text-emerald-400">{service.sub}</span>
</div>
{/each}
{#if runningServices().length > 15}
<div class="text-xs text-slate-500 text-center py-1">
+{runningServices().length - 15} more
</div>
{/if}
</div>
</div>
</div>
{:else}
<div class="h-32 flex flex-col items-center justify-center text-slate-400">
<span class="text-3xl mb-2">⚙️</span>
<span class="text-sm">Systemd not available</span>
<span class="text-xs text-slate-500 mt-1 text-center px-4">
Mount D-Bus: <code class="bg-white/10 px-1 rounded text-xs">-v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro</code>
</span>
</div>
{/if}
</Card>

View File

@@ -69,7 +69,7 @@
}
</script>
<Card title="Temperature" icon="🌡️">
<Card title="Temperature" icon="🌡️" info="Hardware temperature sensors via hwmon. Shows CPU (k10temp/coretemp), GPU (amdgpu), NVMe drives, and other sensors. Colors: green (<70%), yellow (70-80%), red (>80% of critical).">
{#if $temperatureStats && $temperatureStats.sensors.length > 0}
<div class="space-y-3">
<!-- Key sensors as prominent cards -->

View File

@@ -0,0 +1,78 @@
<script lang="ts">
interface Props {
data: number[];
color?: string;
height?: number;
fill?: boolean;
maxValue?: number;
}
let { data = [], color = '#60a5fa', height = 60, fill = true, maxValue }: Props = $props();
const width = 300;
const padding = 2;
const pathD = $derived(() => {
if (data.length < 2) return '';
const max = maxValue ?? Math.max(...data, 1);
const min = 0;
const range = max - min || 1;
const xStep = (width - padding * 2) / (data.length - 1);
const yScale = (height - padding * 2) / range;
const points = data.map((v, i) => {
const x = padding + i * xStep;
const y = height - padding - (v - min) * yScale;
return `${x},${y}`;
});
return 'M' + points.join(' L');
});
const fillPathD = $derived(() => {
if (data.length < 2 || !fill) return '';
const max = maxValue ?? Math.max(...data, 1);
const min = 0;
const range = max - min || 1;
const xStep = (width - padding * 2) / (data.length - 1);
const yScale = (height - padding * 2) / range;
const points = data.map((v, i) => {
const x = padding + i * xStep;
const y = height - padding - (v - min) * yScale;
return `${x},${y}`;
});
const firstX = padding;
const lastX = padding + (data.length - 1) * xStep;
const bottomY = height - padding;
return `M${firstX},${bottomY} L${points.join(' L')} L${lastX},${bottomY} Z`;
});
</script>
<svg {width} {height} viewBox="0 0 {width} {height}" class="w-full h-auto" preserveAspectRatio="none">
{#if data.length >= 2}
{#if fill}
<path
d={fillPathD()}
fill={color}
fill-opacity="0.15"
/>
{/if}
<path
d={pathD()}
fill="none"
stroke={color}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
{:else}
<line x1="0" y1={height/2} x2={width} y2={height/2} stroke="currentColor" stroke-opacity="0.2" />
{/if}
</svg>

View File

@@ -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);
</script>
<div class="card">
@@ -16,6 +18,22 @@
<span class="text-xl">{icon}</span>
{/if}
{title}
{#if info}
<button
class="ml-auto text-slate-500 hover:text-slate-300 transition-colors p-1 -mr-1"
onclick={() => showInfo = !showInfo}
title="Info"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
{/if}
</h2>
{#if info && showInfo}
<div class="mb-4 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20 text-xs text-slate-300">
{info}
</div>
{/if}
{@render children()}
</div>

View File

@@ -0,0 +1,87 @@
import { writable } from 'svelte/store';
import type { Alert, AlertConfig, AlertsResponse, AlertThreshold } from '$lib/types/metrics';
export const activeAlerts = writable<Alert[]>([]);
export const alertHistory = writable<Alert[]>([]);
export const alertConfig = writable<AlertConfig | null>(null);
const API_BASE = '/api/v1';
export async function fetchAlerts(): Promise<void> {
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<boolean> {
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<boolean> {
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<typeof setInterval> | 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;
}
}

View File

@@ -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);
};
}

View File

@@ -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<string, { name: string; icon: string; description: string }> = {
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<T>(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<LayoutSection[]>(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;
});

View File

@@ -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<AllMetrics | null>(null);
@@ -7,6 +7,9 @@ export const metrics = writable<AllMetrics | null>(null);
// Connection status
export const connected = writable(false);
// Historical data from backend
export const historyData = writable<HistoryData | null>(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;

View File

@@ -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<Theme>(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');
}
}

View File

@@ -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;
}

View File

@@ -1,30 +1,60 @@
<script lang="ts">
import '../app.css';
import Header from '$lib/components/Header.svelte';
import KeyboardHelp from '$lib/components/KeyboardHelp.svelte';
import SettingsPanel from '$lib/components/SettingsPanel.svelte';
import DashboardEditor from '$lib/components/DashboardEditor.svelte';
import { onMount, onDestroy } from 'svelte';
import { connectSSE, disconnectSSE } from '$lib/api/sse';
import { theme } from '$lib/stores/theme';
import { initKeyboardShortcuts, showShortcutsHelp } from '$lib/stores/keyboard';
import { showSettings, editMode } from '$lib/stores/layout';
let { children } = $props();
let cleanupKeyboard: (() => void) | undefined;
onMount(() => {
connectSSE();
cleanupKeyboard = initKeyboardShortcuts();
});
onDestroy(() => {
disconnectSSE();
cleanupKeyboard?.();
});
</script>
<div class="min-h-screen text-white">
<div class="min-h-screen {$theme === 'light' ? 'text-slate-800' : 'text-white'}">
<Header />
<main class="container mx-auto px-4 py-6 max-w-7xl">
<main class="container mx-auto px-3 sm:px-4 py-4 sm:py-6 max-w-7xl">
{@render children()}
</main>
<!-- Keyboard shortcuts help modal -->
{#if $showShortcutsHelp}
<KeyboardHelp />
{/if}
<!-- Settings panel modal -->
{#if $showSettings}
<SettingsPanel onClose={() => showSettings.set(false)} />
{/if}
<!-- Dashboard editor -->
{#if $editMode}
<DashboardEditor />
{/if}
<!-- Subtle background effects -->
<div class="fixed inset-0 -z-10 overflow-hidden pointer-events-none">
<div class="absolute top-0 -left-40 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div class="absolute top-1/3 -right-40 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-1/3 w-80 h-80 bg-cyan-500/5 rounded-full blur-3xl"></div>
{#if $theme === 'light'}
<div class="absolute top-0 -left-40 w-80 h-80 bg-blue-300/20 rounded-full blur-3xl"></div>
<div class="absolute top-1/3 -right-40 w-80 h-80 bg-purple-300/20 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-1/3 w-80 h-80 bg-cyan-300/10 rounded-full blur-3xl"></div>
{:else}
<div class="absolute top-0 -left-40 w-80 h-80 bg-blue-500/10 rounded-full blur-3xl"></div>
<div class="absolute top-1/3 -right-40 w-80 h-80 bg-purple-500/10 rounded-full blur-3xl"></div>
<div class="absolute bottom-0 left-1/3 w-80 h-80 bg-cyan-500/5 rounded-full blur-3xl"></div>
{/if}
</div>
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import type { Component } from 'svelte';
import CpuCard from '$lib/components/cards/CpuCard.svelte';
import MemoryCard from '$lib/components/cards/MemoryCard.svelte';
import DiskCard from '$lib/components/cards/DiskCard.svelte';
@@ -6,27 +7,57 @@
import ProcessesCard from '$lib/components/cards/ProcessesCard.svelte';
import TemperatureCard from '$lib/components/cards/TemperatureCard.svelte';
import GpuCard from '$lib/components/cards/GpuCard.svelte';
import DockerCard from '$lib/components/cards/DockerCard.svelte';
import SystemdCard from '$lib/components/cards/SystemdCard.svelte';
import HistoryCard from '$lib/components/cards/HistoryCard.svelte';
import AlertsCard from '$lib/components/cards/AlertsCard.svelte';
import { layout, type CardConfig } from '$lib/stores/layout';
// Map component names to actual components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const cardComponents: Record<string, Component<any>> = {
CpuCard,
MemoryCard,
DiskCard,
NetworkCard,
ProcessesCard,
TemperatureCard,
GpuCard,
DockerCard,
SystemdCard,
HistoryCard,
AlertsCard
};
function getComponent(config: CardConfig) {
return cardComponents[config.component];
}
</script>
<svelte:head>
<title>System Monitor</title>
</svelte:head>
<div class="space-y-5">
<!-- Top row: Primary metrics -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-5">
<CpuCard />
<MemoryCard />
<GpuCard />
</div>
<div class="space-y-4 sm:space-y-5">
{#each $layout as section (section.id)}
{@const visibleCards = section.cards.filter(c => c.visible)}
<!-- Middle row: System info -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<DiskCard />
<NetworkCard />
<TemperatureCard />
</div>
<!-- Bottom: Processes -->
<ProcessesCard />
{#if visibleCards.length > 0}
{#if section.gridCols}
<!-- Multi-card grid section -->
<div class="grid {section.gridCols} gap-4 sm:gap-5">
{#each visibleCards as card (card.id)}
{@const Component = getComponent(card)}
<Component />
{/each}
</div>
{:else}
<!-- Single-card section (full width) -->
{#each visibleCards as card (card.id)}
{@const Component = getComponent(card)}
<Component />
{/each}
{/if}
{/if}
{/each}
</div>

View File

@@ -0,0 +1,84 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
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');
}
})()
);
});

View File

@@ -0,0 +1,14 @@
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
<rect width="192" height="192" rx="24" fill="#1e293b"/>
<rect x="16" y="16" width="160" height="160" rx="16" fill="#0f172a"/>
<!-- Chart lines -->
<path d="M 32 140 Q 56 100 80 120 T 128 80 T 160 60" stroke="#3b82f6" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M 32 140 Q 56 130 80 110 T 128 100 T 160 90" stroke="#8b5cf6" stroke-width="4" fill="none" stroke-linecap="round"/>
<!-- Grid lines -->
<line x1="32" y1="60" x2="160" y2="60" stroke="#334155" stroke-width="1"/>
<line x1="32" y1="100" x2="160" y2="100" stroke="#334155" stroke-width="1"/>
<line x1="32" y1="140" x2="160" y2="140" stroke="#334155" stroke-width="1"/>
<!-- Dots -->
<circle cx="160" cy="60" r="6" fill="#3b82f6"/>
<circle cx="160" cy="90" r="6" fill="#8b5cf6"/>
</svg>

After

Width:  |  Height:  |  Size: 874 B

View File

@@ -0,0 +1,14 @@
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<rect width="512" height="512" rx="64" fill="#1e293b"/>
<rect x="42" y="42" width="428" height="428" rx="42" fill="#0f172a"/>
<!-- Chart lines -->
<path d="M 85 373 Q 149 267 213 320 T 341 213 T 427 160" stroke="#3b82f6" stroke-width="10" fill="none" stroke-linecap="round"/>
<path d="M 85 373 Q 149 347 213 293 T 341 267 T 427 240" stroke="#8b5cf6" stroke-width="10" fill="none" stroke-linecap="round"/>
<!-- Grid lines -->
<line x1="85" y1="160" x2="427" y2="160" stroke="#334155" stroke-width="2"/>
<line x1="85" y1="267" x2="427" y2="267" stroke="#334155" stroke-width="2"/>
<line x1="85" y1="373" x2="427" y2="373" stroke="#334155" stroke-width="2"/>
<!-- Dots -->
<circle cx="427" cy="160" r="16" fill="#3b82f6"/>
<circle cx="427" cy="240" r="16" fill="#8b5cf6"/>
</svg>

After

Width:  |  Height:  |  Size: 889 B

View File

@@ -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"]
}

View File

@@ -11,7 +11,10 @@ const config = {
fallback: 'index.html',
precompress: false,
strict: true
})
}),
serviceWorker: {
register: true
}
}
};