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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
211
backend/internal/alerts/manager.go
Normal file
211
backend/internal/alerts/manager.go
Normal 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]
|
||||
}
|
||||
}
|
||||
344
backend/internal/alerts/manager_test.go
Normal file
344
backend/internal/alerts/manager_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
}
|
||||
|
||||
99
backend/internal/collectors/cpu_test.go
Normal file
99
backend/internal/collectors/cpu_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
201
backend/internal/collectors/docker.go
Normal file
201
backend/internal/collectors/docker.go
Normal 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
|
||||
}
|
||||
107
backend/internal/collectors/memory_test.go
Normal file
107
backend/internal/collectors/memory_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
127
backend/internal/collectors/network_test.go
Normal file
127
backend/internal/collectors/network_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
142
backend/internal/collectors/systemd.go
Normal file
142
backend/internal/collectors/systemd.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
116
backend/internal/history/history.go
Normal file
116
backend/internal/history/history.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
69
backend/internal/models/alerts.go
Normal file
69
backend/internal/models/alerts.go
Normal 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},
|
||||
},
|
||||
}
|
||||
}
|
||||
20
backend/internal/models/docker.go
Normal file
20
backend/internal/models/docker.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
17
backend/internal/models/systemd.go
Normal file
17
backend/internal/models/systemd.go
Normal 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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
2593
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
288
frontend/src/lib/components/DashboardEditor.svelte
Normal file
288
frontend/src/lib/components/DashboardEditor.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
56
frontend/src/lib/components/KeyboardHelp.svelte
Normal file
56
frontend/src/lib/components/KeyboardHelp.svelte
Normal 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>
|
||||
151
frontend/src/lib/components/SettingsPanel.svelte
Normal file
151
frontend/src/lib/components/SettingsPanel.svelte
Normal 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>
|
||||
194
frontend/src/lib/components/cards/AlertsCard.svelte
Normal file
194
frontend/src/lib/components/cards/AlertsCard.svelte
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
97
frontend/src/lib/components/cards/DockerCard.svelte
Normal file
97
frontend/src/lib/components/cards/DockerCard.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
85
frontend/src/lib/components/cards/HistoryCard.svelte
Normal file
85
frontend/src/lib/components/cards/HistoryCard.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
101
frontend/src/lib/components/cards/SystemdCard.svelte
Normal file
101
frontend/src/lib/components/cards/SystemdCard.svelte
Normal 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>
|
||||
@@ -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 -->
|
||||
|
||||
78
frontend/src/lib/components/charts/SparklineChart.svelte
Normal file
78
frontend/src/lib/components/charts/SparklineChart.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
87
frontend/src/lib/stores/alerts.ts
Normal file
87
frontend/src/lib/stores/alerts.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
76
frontend/src/lib/stores/keyboard.ts
Normal file
76
frontend/src/lib/stores/keyboard.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
266
frontend/src/lib/stores/layout.ts
Normal file
266
frontend/src/lib/stores/layout.ts
Normal 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;
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
62
frontend/src/lib/stores/theme.ts
Normal file
62
frontend/src/lib/stores/theme.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
84
frontend/src/service-worker.ts
Normal file
84
frontend/src/service-worker.ts
Normal 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');
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
14
frontend/static/icon-192.svg
Normal file
14
frontend/static/icon-192.svg
Normal 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 |
14
frontend/static/icon-512.svg
Normal file
14
frontend/static/icon-512.svg
Normal 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 |
25
frontend/static/manifest.json
Normal file
25
frontend/static/manifest.json
Normal 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"]
|
||||
}
|
||||
@@ -11,7 +11,10 @@ const config = {
|
||||
fallback: 'index.html',
|
||||
precompress: false,
|
||||
strict: true
|
||||
})
|
||||
}),
|
||||
serviceWorker: {
|
||||
register: true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user