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 := `
| Name | Status | CPU % | Memory | Disk Read | Disk Write | Net In | Net Out |
|---|