Pulse/internal/api/router.go
Pulse Monitor 378ebcb250 Major improvements to security, alerts, and ease of use
Security enhancements:
- Fixed critical issue: PBS tokens no longer logged in plaintext
- PVE tokens now properly masked in all log outputs
- Enhanced token security documentation

Alert system fixes:
- Fixed storage alerts not working due to threshold being 0
- Added automatic defaults preservation for alert thresholds
- Storage alerts now properly trigger at 85% usage

Node management improvements:
- Fixed node deletion causing 'Node not found' errors
- Added instant discovery refresh when nodes are deleted
- Added manual refresh buttons for discovery
- Fixed PBS token cleanup in auto-registration scripts
- Fixed /dev/tty errors when running scripts in Docker containers

Bug fixes:
- Fixed CPU MHz field type mismatch causing JSON unmarshal errors
- Suppressed non-critical container snapshot API errors
- Fixed auto-registration using Docker internal IPs instead of actual host IPs

Documentation updates:
- Added comprehensive security documentation
- Streamlined setup documentation focusing on ease of use
- Removed marketing language and consolidated repetitive content

Frontend improvements:
- Added WebSocket support for real-time node updates
- Added discovery refresh buttons in Settings
- Improved node deletion feedback
2025-08-11 13:59:58 +00:00

1222 lines
38 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/updates"
"github.com/rcourtman/pulse-go-rewrite/internal/tokens"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
// Router handles HTTP routing
type Router struct {
mux *http.ServeMux
config *config.Config
monitor *monitoring.Monitor
wsHub *websocket.Hub
reloadFunc func() error
updateManager *updates.Manager
exportLimiter *RateLimiter
tokenManager *tokens.TokenManager
}
// NewRouter creates a new router instance
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error) http.Handler {
r := &Router{
mux: http.NewServeMux(),
config: cfg,
monitor: monitor,
wsHub: wsHub,
reloadFunc: reloadFunc,
updateManager: updates.NewManager(cfg),
exportLimiter: NewRateLimiter(5, 1*time.Minute), // 5 attempts per minute
tokenManager: tokens.NewTokenManager(cfg.DataPath),
}
r.setupRoutes()
// Start forwarding update progress to WebSocket
go r.forwardUpdateProgress()
// Wrap with error handler middleware only
// Note: TimeoutHandler breaks WebSocket upgrades
return ErrorHandler(r)
}
// handleDiscovery returns cached discovery results
func (r *Router) handleDiscovery(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get cached discovery results from the monitor's discovery service
if r.monitor != nil && r.monitor.GetDiscoveryService() != nil {
result, _ := r.monitor.GetDiscoveryService().GetCachedResult()
if result != nil {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
return
}
}
// Return empty result if no discovery service or no cached results
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"servers": []interface{}{},
"errors": []string{},
})
}
// setupRoutes configures all routes
func (r *Router) setupRoutes() {
// Create handlers
alertHandlers := NewAlertHandlers(r.monitor)
notificationHandlers := NewNotificationHandlers(r.monitor)
configHandlers := NewConfigHandlers(r.config, r.monitor, r.reloadFunc, r.wsHub, r.tokenManager)
updateHandlers := NewUpdateHandlers(r.updateManager)
// API routes
r.mux.HandleFunc("/api/health", r.handleHealth)
r.mux.HandleFunc("/api/state", r.handleState)
r.mux.HandleFunc("/api/version", r.handleVersion)
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/diagnostics", r.handleDiagnostics)
r.mux.HandleFunc("/api/config", r.handleConfig)
r.mux.HandleFunc("/api/backups", r.handleBackups)
r.mux.HandleFunc("/api/backups/", r.handleBackups)
r.mux.HandleFunc("/api/backups/unified", r.handleBackups)
r.mux.HandleFunc("/api/backups/pve", r.handleBackupsPVE)
r.mux.HandleFunc("/api/backups/pbs", r.handleBackupsPBS)
r.mux.HandleFunc("/api/snapshots", r.handleSnapshots)
// Update routes
r.mux.HandleFunc("/api/updates/check", updateHandlers.HandleCheckUpdates)
r.mux.HandleFunc("/api/updates/apply", updateHandlers.HandleApplyUpdate)
r.mux.HandleFunc("/api/updates/status", updateHandlers.HandleUpdateStatus)
// Config management routes
r.mux.HandleFunc("/api/config/nodes", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
configHandlers.HandleGetNodes(w, r)
case http.MethodPost:
configHandlers.HandleAddNode(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Test node configuration endpoint (for new nodes)
r.mux.HandleFunc("/api/config/nodes/test-config", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
configHandlers.HandleTestNodeConfig(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Test connection endpoint
r.mux.HandleFunc("/api/config/nodes/test-connection", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
configHandlers.HandleTestConnection(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
r.mux.HandleFunc("/api/config/nodes/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
configHandlers.HandleUpdateNode(w, r)
case http.MethodDelete:
configHandlers.HandleDeleteNode(w, r)
case http.MethodPost:
// Handle test endpoint
if strings.HasSuffix(r.URL.Path, "/test") {
configHandlers.HandleTestNode(w, r)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// System settings routes
r.mux.HandleFunc("/api/config/system", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
configHandlers.HandleGetSystemSettings(w, r)
case http.MethodPut:
configHandlers.HandleUpdateSystemSettings(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Registration token routes
r.mux.HandleFunc("/api/tokens/generate", RequireAuth(r.config, r.handleGenerateToken))
r.mux.HandleFunc("/api/tokens/list", RequireAuth(r.config, r.handleListTokens))
r.mux.HandleFunc("/api/tokens/revoke", RequireAuth(r.config, r.handleRevokeToken))
// Security status route
r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodGet {
w.Header().Set("Content-Type", "application/json")
status := map[string]interface{}{
"apiTokenConfigured": r.config.APIToken != "",
"requiresAuth": r.config.APIToken != "",
"exportProtected": r.config.APIToken != "" || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
"unprotectedExportAllowed": os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
"registrationTokensEnabled": os.Getenv("REQUIRE_REGISTRATION_TOKEN") == "true",
}
json.NewEncoder(w).Encode(status)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Config export/import routes (requires API token for security)
r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check for API token if configured
if r.config.APIToken != "" {
authHeader := req.Header.Get("X-API-Token")
if authHeader != r.config.APIToken {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Unauthorized export attempt")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
} else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" {
// If no API token and unprotected export not explicitly allowed
log.Warn().
Str("ip", req.RemoteAddr).
Msg("Export blocked - API token required")
http.Error(w, "Export requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden)
return
}
// Log successful export attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("authenticated", r.config.APIToken != "").
Msg("Configuration export initiated")
configHandlers.HandleExportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
r.mux.HandleFunc("/api/config/import", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check for API token if configured
if r.config.APIToken != "" {
authHeader := req.Header.Get("X-API-Token")
if authHeader != r.config.APIToken {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Unauthorized import attempt")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
} else if os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true" {
// If no API token and unprotected import not explicitly allowed
log.Warn().
Str("ip", req.RemoteAddr).
Msg("Import blocked - API token required")
http.Error(w, "Import requires API_TOKEN to be set (or set ALLOW_UNPROTECTED_EXPORT=true for homelab use)", http.StatusForbidden)
return
}
// Log successful import attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("authenticated", r.config.APIToken != "").
Msg("Configuration import initiated")
configHandlers.HandleImportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}))
// Discovery route
// Setup script route
r.mux.HandleFunc("/api/setup-script", configHandlers.HandleSetupScript)
// Auto-register route for setup scripts
r.mux.HandleFunc("/api/auto-register", configHandlers.HandleAutoRegister)
// Discovery endpoint
r.mux.HandleFunc("/api/discover", r.handleDiscovery)
// Test endpoint for WebSocket notifications
r.mux.HandleFunc("/api/test-notification", func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Send a test auto-registration notification
r.wsHub.BroadcastMessage(websocket.Message{
Type: "node_auto_registered",
Data: map[string]interface{}{
"type": "pve",
"host": "test-node.example.com",
"name": "Test Node",
"tokenId": "test-token",
"hasToken": true,
},
Timestamp: time.Now().Format(time.RFC3339),
})
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "notification sent"})
})
// Alert routes
r.mux.HandleFunc("/api/alerts/", alertHandlers.HandleAlerts)
// Notification routes
r.mux.HandleFunc("/api/notifications/", notificationHandlers.HandleNotifications)
// Settings routes
r.mux.HandleFunc("/api/settings", getSettings)
r.mux.HandleFunc("/api/settings/update", updateSettings)
// WebSocket endpoint
r.mux.HandleFunc("/ws", r.handleWebSocket)
// Socket.io compatibility endpoints
r.mux.HandleFunc("/socket.io/", r.handleSocketIO)
// Simple stats page
r.mux.HandleFunc("/simple-stats", r.handleSimpleStats)
// Serve static files from frontend build
// First try to find frontend relative to the executable
staticDir := "./frontend-modern/dist"
if execPath, err := os.Executable(); err == nil {
execDir := filepath.Dir(execPath)
possibleDir := filepath.Join(execDir, "frontend-modern", "dist")
if _, err := os.Stat(possibleDir); err == nil {
staticDir = possibleDir
}
}
// Fallback to working directory
if _, err := os.Stat(staticDir); err != nil {
// Try /opt/pulse as last resort
if _, err := os.Stat("/opt/pulse/frontend-modern/dist"); err == nil {
staticDir = "/opt/pulse/frontend-modern/dist"
}
}
log.Info().Str("staticDir", staticDir).Msg("Serving frontend from")
fileServer := http.FileServer(http.Dir(staticDir))
r.mux.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// Try to serve the file
path := req.URL.Path
if path == "/" {
path = "/index.html"
}
// Check if file exists
if _, err := http.Dir(staticDir).Open(strings.TrimPrefix(path, "/")); err == nil {
fileServer.ServeHTTP(w, req)
return
}
// For SPA routing, serve index.html for non-existent paths
if !strings.HasPrefix(path, "/api/") && !strings.HasPrefix(path, "/ws") && !strings.HasPrefix(path, "/socket.io/") {
req.URL.Path = "/index.html"
fileServer.ServeHTTP(w, req)
return
}
// Otherwise, return 404
http.NotFound(w, req)
}))
}
// ServeHTTP implements http.Handler
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Add CORS headers if configured
if r.config.AllowedOrigins != "" {
w.Header().Set("Access-Control-Allow-Origin", r.config.AllowedOrigins)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
}
// Handle preflight requests
if req.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Add security headers for API endpoints
if strings.HasPrefix(req.URL.Path, "/api/") || strings.HasPrefix(req.URL.Path, "/ws") {
r.addSecurityHeaders(w)
}
// Log request
start := time.Now()
r.mux.ServeHTTP(w, req)
log.Debug().
Str("method", req.Method).
Str("path", req.URL.Path).
Dur("duration", time.Since(start)).
Msg("Request handled")
}
// addSecurityHeaders adds security headers to the response
func (r *Router) addSecurityHeaders(w http.ResponseWriter) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", r.config.IframeEmbeddingAllow)
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// CSP header
csp := "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://cdn.socket.io; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: https:; connect-src 'self' ws: wss: http: https:; font-src 'self' https://cdn.jsdelivr.net;"
w.Header().Set("Content-Security-Policy", csp)
}
// handleHealth handles health check requests
func (r *Router) handleHealth(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
health := map[string]interface{}{
"status": "healthy",
"timestamp": time.Now().Unix(),
"uptime": time.Since(r.monitor.GetStartTime()).Seconds(),
}
utils.WriteJSONResponse(w, health)
}
// handleState handles state requests
func (r *Router) handleState(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only GET method is allowed", nil)
return
}
// Check API token if configured
if r.config.APIToken != "" {
token := req.Header.Get("Authorization")
if token == "" {
token = req.URL.Query().Get("token")
}
if token != r.config.APIToken && token != "Bearer "+r.config.APIToken {
writeErrorResponse(w, http.StatusUnauthorized, "unauthorized",
"Invalid or missing API token", nil)
return
}
}
state := r.monitor.GetState()
if err := utils.WriteJSONResponse(w, state); err != nil {
log.Error().Err(err).Msg("Failed to encode state response")
writeErrorResponse(w, http.StatusInternalServerError, "encoding_error",
"Failed to encode state data", nil)
}
}
// handleVersion handles version requests
func (r *Router) handleVersion(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
versionInfo, err := updates.GetCurrentVersion()
if err != nil {
// Fallback to VERSION file
versionBytes, _ := os.ReadFile("VERSION")
version := map[string]interface{}{
"version": strings.TrimSpace(string(versionBytes)),
"build": "development",
"runtime": "go",
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(version)
return
}
// Add update channel from config
versionInfo.Channel = r.config.UpdateChannel
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(versionInfo)
}
// handleStorage handles storage detail requests
func (r *Router) handleStorage(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed",
"Only GET method is allowed", nil)
return
}
// Extract storage ID from path
path := strings.TrimPrefix(req.URL.Path, "/api/storage/")
if path == "" {
writeErrorResponse(w, http.StatusBadRequest, "missing_storage_id",
"Storage ID is required", nil)
return
}
// Get current state
state := r.monitor.GetState()
// Find the storage by ID
var storageDetail *models.Storage
for _, storage := range state.Storage {
if storage.ID == path {
storageDetail = &storage
break
}
}
if storageDetail == nil {
writeErrorResponse(w, http.StatusNotFound, "storage_not_found",
fmt.Sprintf("Storage with ID '%s' not found", path), nil)
return
}
// Return storage details
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"data": storageDetail,
"timestamp": time.Now().Unix(),
}); err != nil {
log.Error().Err(err).Str("storage_id", path).Msg("Failed to encode storage details")
writeErrorResponse(w, http.StatusInternalServerError, "encoding_error",
"Failed to encode response", nil)
}
}
// handleCharts handles chart data requests
func (r *Router) handleCharts(w http.ResponseWriter, req *http.Request) {
log.Debug().Str("method", req.Method).Str("url", req.URL.String()).Msg("Charts endpoint hit")
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get time range from query parameters
query := req.URL.Query()
timeRange := query.Get("range")
if timeRange == "" {
timeRange = "1h"
}
// Convert time range to duration
var duration time.Duration
switch timeRange {
case "5m":
duration = 5 * time.Minute
case "15m":
duration = 15 * time.Minute
case "30m":
duration = 30 * time.Minute
case "1h":
duration = time.Hour
case "4h":
duration = 4 * time.Hour
case "12h":
duration = 12 * time.Hour
case "24h":
duration = 24 * time.Hour
case "7d":
duration = 7 * 24 * time.Hour
default:
duration = time.Hour
}
// Get current state from monitor
state := r.monitor.GetState()
// Create chart data structure that matches frontend expectations
chartData := make(map[string]map[string][]map[string]interface{})
nodeData := make(map[string]map[string][]map[string]interface{})
currentTime := time.Now().Unix() * 1000 // JavaScript timestamp format
oldestTimestamp := currentTime
// Process VMs - get historical data
for _, vm := range state.VMs {
if chartData[vm.ID] == nil {
chartData[vm.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetGuestMetrics(vm.ID, duration)
// Convert metric points to API format
for metricType, points := range metrics {
chartData[vm.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
chartData[vm.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
}
// If no historical data, add current value
if len(chartData[vm.ID]["cpu"]) == 0 {
chartData[vm.ID]["cpu"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.CPU * 100},
}
chartData[vm.ID]["memory"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.Memory.Usage},
}
chartData[vm.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.Disk.Usage},
}
chartData[vm.ID]["diskread"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.DiskRead},
}
chartData[vm.ID]["diskwrite"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.DiskWrite},
}
chartData[vm.ID]["netin"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.NetworkIn},
}
chartData[vm.ID]["netout"] = []map[string]interface{}{
{"timestamp": currentTime, "value": vm.NetworkOut},
}
}
}
// Process Containers - get historical data
for _, ct := range state.Containers {
if chartData[ct.ID] == nil {
chartData[ct.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetGuestMetrics(ct.ID, duration)
// Convert metric points to API format
for metricType, points := range metrics {
chartData[ct.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
chartData[ct.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
}
// If no historical data, add current value
if len(chartData[ct.ID]["cpu"]) == 0 {
chartData[ct.ID]["cpu"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.CPU * 100},
}
chartData[ct.ID]["memory"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.Memory.Usage},
}
chartData[ct.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.Disk.Usage},
}
chartData[ct.ID]["diskread"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.DiskRead},
}
chartData[ct.ID]["diskwrite"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.DiskWrite},
}
chartData[ct.ID]["netin"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.NetworkIn},
}
chartData[ct.ID]["netout"] = []map[string]interface{}{
{"timestamp": currentTime, "value": ct.NetworkOut},
}
}
}
// Process Storage - get historical data
storageData := make(map[string]map[string][]map[string]interface{})
for _, storage := range state.Storage {
if storageData[storage.ID] == nil {
storageData[storage.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics
metrics := r.monitor.GetStorageMetrics(storage.ID, duration)
// Convert usage metrics to chart format
if usagePoints, ok := metrics["usage"]; ok && len(usagePoints) > 0 {
// Convert MetricPoint slice to chart format
storageData[storage.ID]["disk"] = make([]map[string]interface{}, len(usagePoints))
for i, point := range usagePoints {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
storageData[storage.ID]["disk"][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
} else {
// Add current value if no historical data
usagePercent := float64(0)
if storage.Total > 0 {
usagePercent = (float64(storage.Used) / float64(storage.Total)) * 100
}
storageData[storage.ID]["disk"] = []map[string]interface{}{
{"timestamp": currentTime, "value": usagePercent},
}
}
}
// Process Nodes - get historical data
for _, node := range state.Nodes {
if nodeData[node.ID] == nil {
nodeData[node.ID] = make(map[string][]map[string]interface{})
}
// Get historical metrics for each type
for _, metricType := range []string{"cpu", "memory", "disk"} {
points := r.monitor.GetNodeMetrics(node.ID, metricType, duration)
nodeData[node.ID][metricType] = make([]map[string]interface{}, len(points))
for i, point := range points {
ts := point.Timestamp.Unix() * 1000
if ts < oldestTimestamp {
oldestTimestamp = ts
}
nodeData[node.ID][metricType][i] = map[string]interface{}{
"timestamp": ts,
"value": point.Value,
}
}
// If no historical data, add current value
if len(nodeData[node.ID][metricType]) == 0 {
var value float64
switch metricType {
case "cpu":
value = node.CPU * 100
case "memory":
value = node.Memory.Usage
case "disk":
value = node.Disk.Usage
}
nodeData[node.ID][metricType] = []map[string]interface{}{
{"timestamp": currentTime, "value": value},
}
}
}
}
response := map[string]interface{}{
"data": chartData,
"nodeData": nodeData,
"storageData": storageData,
"timestamp": currentTime,
"stats": map[string]interface{}{
"oldestDataTimestamp": oldestTimestamp,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(response); err != nil {
log.Error().Err(err).Msg("Failed to encode chart data response")
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
log.Debug().
Int("guests", len(chartData)).
Int("nodes", len(nodeData)).
Int("storage", len(storageData)).
Str("range", timeRange).
Msg("Chart data response sent")
}
// handleStorageCharts handles storage chart data requests
func (r *Router) handleStorageCharts(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse query parameters
query := req.URL.Query()
rangeMinutes := 60 // default 1 hour
if rangeStr := query.Get("range"); rangeStr != "" {
fmt.Sscanf(rangeStr, "%d", &rangeMinutes)
}
duration := time.Duration(rangeMinutes) * time.Minute
state := r.monitor.GetState()
// Build storage chart data
storageData := make(map[string]interface{})
for _, storage := range state.Storage {
metrics := r.monitor.GetStorageMetrics(storage.ID, duration)
storageData[storage.ID] = map[string]interface{}{
"usage": metrics["usage"],
"used": metrics["used"],
"total": metrics["total"],
"avail": metrics["avail"],
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(storageData); err != nil {
log.Error().Err(err).Msg("Failed to encode storage chart data")
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// handleConfig handles configuration requests
func (r *Router) handleConfig(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Return public configuration
config := map[string]interface{}{
"pollingInterval": r.config.PollingInterval.Seconds(),
"csrfProtection": false, // Not implemented yet
"autoUpdateEnabled": r.config.AutoUpdateEnabled,
"updateChannel": r.config.UpdateChannel,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
// handleBackups handles backup requests
func (r *Router) handleBackups(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get current state
state := r.monitor.GetState()
// Return backup data structure
backups := map[string]interface{}{
"backupTasks": state.PVEBackups.BackupTasks,
"storageBackups": state.PVEBackups.StorageBackups,
"guestSnapshots": state.PVEBackups.GuestSnapshots,
"pbsBackups": state.PBSBackups,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(backups)
}
// handleBackupsPVE handles PVE backup requests
func (r *Router) handleBackupsPVE(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract PVE backups
state := r.monitor.GetState()
// Return PVE backup data in expected format
backups := state.PVEBackups.StorageBackups
if backups == nil {
backups = []models.StorageBackup{}
}
pveBackups := map[string]interface{}{
"backups": backups,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pveBackups); err != nil {
log.Error().Err(err).Msg("Failed to encode PVE backups response")
// Return empty array as fallback
w.Write([]byte(`{"backups":[]}`))
}
}
// handleBackupsPBS handles PBS backup requests
func (r *Router) handleBackupsPBS(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract PBS backups
state := r.monitor.GetState()
// Return PBS backup data in expected format
instances := state.PBSInstances
if instances == nil {
instances = []models.PBSInstance{}
}
pbsData := map[string]interface{}{
"instances": instances,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(pbsData); err != nil {
log.Error().Err(err).Msg("Failed to encode PBS response")
// Return empty array as fallback
w.Write([]byte(`{"instances":[]}`))
}
}
// handleSnapshots handles snapshot requests
func (r *Router) handleSnapshots(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Get state and extract guest snapshots
state := r.monitor.GetState()
// Return snapshot data
snaps := state.PVEBackups.GuestSnapshots
if snaps == nil {
snaps = []models.GuestSnapshot{}
}
snapshots := map[string]interface{}{
"snapshots": snaps,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(snapshots); err != nil {
log.Error().Err(err).Msg("Failed to encode snapshots response")
// Return empty array as fallback
w.Write([]byte(`{"snapshots":[]}`))
}
}
// handleWebSocket handles WebSocket connections
func (r *Router) handleWebSocket(w http.ResponseWriter, req *http.Request) {
r.wsHub.HandleWebSocket(w, req)
}
// handleSimpleStats serves a simple stats page
func (r *Router) handleSimpleStats(w http.ResponseWriter, req *http.Request) {
html := `<!DOCTYPE html>
<html>
<head>
<title>Simple Pulse Stats</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 20px;
background: #f5f5f5;
}
table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background: #333;
color: white;
font-weight: bold;
position: sticky;
top: 0;
}
tr:hover {
background: #f5f5f5;
}
.status {
padding: 4px 8px;
border-radius: 4px;
color: white;
font-size: 12px;
}
.running { background: #28a745; }
.stopped { background: #dc3545; }
#status {
margin-bottom: 20px;
padding: 10px;
background: #e9ecef;
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.update-indicator {
display: inline-block;
width: 10px;
height: 10px;
background: #28a745;
border-radius: 50%;
animation: pulse 0.5s ease-out;
}
@keyframes pulse {
0% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.7; }
100% { transform: scale(1); opacity: 1; }
}
.update-timer {
font-family: monospace;
font-size: 14px;
color: #666;
}
.metric {
font-family: monospace;
text-align: right;
}
</style>
</head>
<body>
<h1>Simple Pulse Stats</h1>
<div id="status">
<div>
<span id="status-text">Connecting...</span>
<span class="update-indicator" id="update-indicator" style="display:none"></span>
</div>
<div class="update-timer" id="update-timer"></div>
</div>
<h2>Containers</h2>
<table id="containers">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>CPU %</th>
<th>Memory</th>
<th>Disk Read</th>
<th>Disk Write</th>
<th>Net In</th>
<th>Net Out</th>
</tr>
</thead>
<tbody></tbody>
</table>
<script>
let ws;
let lastUpdateTime = null;
let updateCount = 0;
let updateInterval = null;
function formatBytes(bytes) {
if (!bytes || bytes < 0) return '0 B/s';
const units = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
let i = 0;
let value = bytes;
while (value >= 1024 && i < units.length - 1) {
value /= 1024;
i++;
}
return value.toFixed(1) + ' ' + units[i];
}
function formatMemory(used, total) {
const usedGB = (used / 1024 / 1024 / 1024).toFixed(1);
const totalGB = (total / 1024 / 1024 / 1024).toFixed(1);
const percent = ((used / total) * 100).toFixed(0);
return usedGB + '/' + totalGB + ' GB (' + percent + '%)';
}
function updateTable(containers) {
const tbody = document.querySelector('#containers tbody');
tbody.innerHTML = '';
containers.sort((a, b) => a.name.localeCompare(b.name));
containers.forEach(ct => {
const row = document.createElement('tr');
row.innerHTML =
'<td><strong>' + ct.name + '</strong></td>' +
'<td><span class="status ' + ct.status + '">' + ct.status + '</span></td>' +
'<td class="metric">' + (ct.cpu ? ct.cpu.toFixed(1) : '0.0') + '%</td>' +
'<td class="metric">' + formatMemory(ct.mem || 0, ct.maxmem || 1) + '</td>' +
'<td class="metric">' + formatBytes(ct.diskread) + '</td>' +
'<td class="metric">' + formatBytes(ct.diskwrite) + '</td>' +
'<td class="metric">' + formatBytes(ct.netin) + '</td>' +
'<td class="metric">' + formatBytes(ct.netout) + '</td>';
tbody.appendChild(row);
});
}
function updateTimer() {
if (lastUpdateTime) {
const secondsSince = Math.floor((Date.now() - lastUpdateTime) / 1000);
document.getElementById('update-timer').textContent = 'Next update in: ' + (2 - (secondsSince % 2)) + 's';
}
}
function connect() {
const statusText = document.getElementById('status-text');
const indicator = document.getElementById('update-indicator');
statusText.textContent = 'Connecting to WebSocket...';
ws = new WebSocket('ws://' + window.location.host + '/ws');
ws.onopen = function() {
statusText.textContent = 'Connected! Updates every 2 seconds';
console.log('WebSocket connected');
// Start the countdown timer
if (updateInterval) clearInterval(updateInterval);
updateInterval = setInterval(updateTimer, 100);
};
ws.onmessage = function(event) {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'initialState' || msg.type === 'rawData') {
if (msg.data && msg.data.containers) {
updateCount++;
lastUpdateTime = Date.now();
// Show update indicator with animation
indicator.style.display = 'inline-block';
indicator.style.animation = 'none';
setTimeout(() => {
indicator.style.animation = 'pulse 0.5s ease-out';
}, 10);
statusText.textContent = 'Update #' + updateCount + ' at ' + new Date().toLocaleTimeString();
updateTable(msg.data.containers);
}
}
} catch (err) {
console.error('Parse error:', err);
}
};
ws.onclose = function(event) {
statusText.textContent = 'Disconnected: ' + event.code + ' ' + event.reason + '. Reconnecting in 3s...';
indicator.style.display = 'none';
if (updateInterval) clearInterval(updateInterval);
setTimeout(connect, 3000);
};
ws.onerror = function(error) {
statusText.textContent = 'Connection error. Retrying...';
console.error('WebSocket error:', error);
};
}
// Start connection
connect();
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(html))
}
// handleSocketIO handles socket.io requests
func (r *Router) handleSocketIO(w http.ResponseWriter, req *http.Request) {
// For socket.io.js, redirect to CDN
if strings.Contains(req.URL.Path, "socket.io.js") {
http.Redirect(w, req, "https://cdn.socket.io/4.8.1/socket.io.min.js", http.StatusFound)
return
}
// For other socket.io endpoints, use our WebSocket
// This provides basic compatibility
if strings.Contains(req.URL.RawQuery, "transport=websocket") {
r.wsHub.HandleWebSocket(w, req)
return
}
// For polling transport, return proper socket.io response
// Socket.io v4 expects specific format
if strings.Contains(req.URL.RawQuery, "transport=polling") {
if strings.Contains(req.URL.RawQuery, "sid=") {
// Already connected, return empty poll
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
w.Write([]byte("6"))
} else {
// Initial handshake
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(http.StatusOK)
// Send open packet with session ID and config
sessionID := fmt.Sprintf("%d", time.Now().UnixNano())
response := fmt.Sprintf(`0{"sid":"%s","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":60000}`, sessionID)
w.Write([]byte(response))
}
return
}
// Default: redirect to WebSocket
http.Redirect(w, req, "/ws", http.StatusFound)
}
// forwardUpdateProgress forwards update progress to WebSocket clients
func (r *Router) forwardUpdateProgress() {
progressChan := r.updateManager.GetProgressChannel()
for status := range progressChan {
// Create update event for WebSocket
message := websocket.Message{
Type: "update:progress",
Data: status,
Timestamp: time.Now().Format(time.RFC3339),
}
// Broadcast to all connected clients
r.wsHub.BroadcastMessage(message)
// Log progress
log.Debug().
Str("status", status.Status).
Int("progress", status.Progress).
Str("message", status.Message).
Msg("Update progress")
}
}