diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index c0b727e27..e73b67d1d 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -1,5 +1,7 @@ package main +// rebuild trigger + import ( "context" "fmt" diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index a6f2ab258..b542d3fa0 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -227,10 +227,7 @@ export default defineConfig({ // OpenCode API proxies - when OpenCode is embedded in iframe, its frontend // makes requests to window.location.origin. We proxy these to the backend // which forwards them to OpenCode's actual backend. - '/global': { - target: backendUrl, - changeOrigin: true, - }, + // Note: /global is OpenCode's client-side route, not an API endpoint '/session': { target: backendUrl, changeOrigin: true, diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 3886f88f9..727a0b3ea 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -518,6 +518,11 @@ func (h *AIHandler) HandleOpenCodeAPI(w http.ResponseWriter, r *http.Request) { originalDirector(req) // Keep the path as-is (no stripping) req.Host = target.Host + // OpenCode uses Accept header to distinguish API vs SPA requests + // Set Accept: application/json for API requests so we get JSON not HTML + if req.Header.Get("Accept") == "" || req.Header.Get("Accept") == "*/*" { + req.Header.Set("Accept", "application/json") + } } // Handle WebSocket upgrades (for /pty/ and other real-time endpoints) diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index bebf0c50b..19329f834 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -371,32 +371,33 @@ func (h *ConfigHandlers) maybeRefreshClusterInfo(instance *config.PVEInstance) { // NodeConfigRequest represents a request to add/update a node type NodeConfigRequest struct { - Type string `json:"type"` // "pve", "pbs", or "pmg" - Name string `json:"name"` - Host string `json:"host"` - GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation) - User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` - TokenName string `json:"tokenName,omitempty"` - TokenValue string `json:"tokenValue,omitempty"` - Fingerprint string `json:"fingerprint,omitempty"` - VerifySSL *bool `json:"verifySSL,omitempty"` - MonitorVMs *bool `json:"monitorVMs,omitempty"` // PVE only - MonitorContainers *bool `json:"monitorContainers,omitempty"` // PVE only - MonitorStorage *bool `json:"monitorStorage,omitempty"` // PVE only - MonitorBackups *bool `json:"monitorBackups,omitempty"` // PVE only - MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` // PVE only (nil = enabled by default) - PhysicalDiskPollingMinutes *int `json:"physicalDiskPollingMinutes,omitempty"` // PVE only (0 = default 5m) - TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // All types (nil = use global setting) - MonitorDatastores *bool `json:"monitorDatastores,omitempty"` // PBS only - MonitorSyncJobs *bool `json:"monitorSyncJobs,omitempty"` // PBS only - MonitorVerifyJobs *bool `json:"monitorVerifyJobs,omitempty"` // PBS only - MonitorPruneJobs *bool `json:"monitorPruneJobs,omitempty"` // PBS only - MonitorGarbageJobs *bool `json:"monitorGarbageJobs,omitempty"` // PBS only - MonitorMailStats *bool `json:"monitorMailStats,omitempty"` // PMG only - MonitorQueues *bool `json:"monitorQueues,omitempty"` // PMG only - MonitorQuarantine *bool `json:"monitorQuarantine,omitempty"` // PMG only - MonitorDomainStats *bool `json:"monitorDomainStats,omitempty"` // PMG only + Type string `json:"type"` // "pve", "pbs", or "pmg" + Name string `json:"name"` + Host string `json:"host"` + GuestURL string `json:"guestURL,omitempty"` // Optional guest-accessible URL (for navigation) + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + TokenName string `json:"tokenName,omitempty"` + TokenValue string `json:"tokenValue,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + VerifySSL *bool `json:"verifySSL,omitempty"` + MonitorVMs *bool `json:"monitorVMs,omitempty"` // PVE only + MonitorContainers *bool `json:"monitorContainers,omitempty"` // PVE only + MonitorStorage *bool `json:"monitorStorage,omitempty"` // PVE only + MonitorBackups *bool `json:"monitorBackups,omitempty"` // PVE only + MonitorPhysicalDisks *bool `json:"monitorPhysicalDisks,omitempty"` // PVE only (nil = enabled by default) + PhysicalDiskPollingMinutes *int `json:"physicalDiskPollingMinutes,omitempty"` // PVE only (0 = default 5m) + TemperatureMonitoringEnabled *bool `json:"temperatureMonitoringEnabled,omitempty"` // All types (nil = use global setting) + MonitorDatastores *bool `json:"monitorDatastores,omitempty"` // PBS only + MonitorSyncJobs *bool `json:"monitorSyncJobs,omitempty"` // PBS only + MonitorVerifyJobs *bool `json:"monitorVerifyJobs,omitempty"` // PBS only + MonitorPruneJobs *bool `json:"monitorPruneJobs,omitempty"` // PBS only + MonitorGarbageJobs *bool `json:"monitorGarbageJobs,omitempty"` // PBS only + ExcludeDatastores []string `json:"excludeDatastores,omitempty"` // PBS only - datastores to exclude from monitoring + MonitorMailStats *bool `json:"monitorMailStats,omitempty"` // PMG only + MonitorQueues *bool `json:"monitorQueues,omitempty"` // PMG only + MonitorQuarantine *bool `json:"monitorQuarantine,omitempty"` // PMG only + MonitorDomainStats *bool `json:"monitorDomainStats,omitempty"` // PMG only } // NodeResponse represents a node in API responses @@ -425,6 +426,7 @@ type NodeResponse struct { MonitorVerifyJobs bool `json:"monitorVerifyJobs,omitempty"` MonitorPruneJobs bool `json:"monitorPruneJobs,omitempty"` MonitorGarbageJobs bool `json:"monitorGarbageJobs,omitempty"` + ExcludeDatastores []string `json:"excludeDatastores,omitempty"` // PBS only MonitorMailStats bool `json:"monitorMailStats,omitempty"` MonitorQueues bool `json:"monitorQueues,omitempty"` MonitorQuarantine bool `json:"monitorQuarantine,omitempty"` @@ -1064,6 +1066,7 @@ func (h *ConfigHandlers) GetAllNodesForAPI() []NodeResponse { MonitorVerifyJobs: pbs.MonitorVerifyJobs, MonitorPruneJobs: pbs.MonitorPruneJobs, MonitorGarbageJobs: pbs.MonitorGarbageJobs, + ExcludeDatastores: pbs.ExcludeDatastores, Status: h.getNodeStatus("pbs", pbs.Name), Source: pbs.Source, } @@ -2472,6 +2475,10 @@ func (h *ConfigHandlers) HandleUpdateNode(w http.ResponseWriter, r *http.Request if req.TemperatureMonitoringEnabled != nil { pbs.TemperatureMonitoringEnabled = req.TemperatureMonitoringEnabled } + // Update datastore exclusion list + if req.ExcludeDatastores != nil { + pbs.ExcludeDatastores = req.ExcludeDatastores + } } else if nodeType == "pmg" && index < len(h.config.PMGInstances) { pmgInst := &h.config.PMGInstances[index] pmgInst.Name = req.Name diff --git a/internal/api/router.go b/internal/api/router.go index 2ec482b56..95a020929 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1415,8 +1415,8 @@ func (r *Router) setupRoutes() { // NOTE: Register both /path and /path/ because Go's ServeMux treats them differently: // - /path/ matches any path starting with /path/ // - /path (no trailing slash) matches exactly /path + // Note: /global is a client-side route in OpenCode, not an API endpoint openCodeAPIBases := []string{ - "/global", "/session", "/tui", "/config", @@ -2455,7 +2455,7 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { strings.HasPrefix(req.URL.Path, "/opencode") || // OpenCode API paths - proxied to OpenCode backend for iframe embedding // Note: Use "/path" (not "/path/") to match both exact and prefix paths - strings.HasPrefix(req.URL.Path, "/global") || + // Note: /global is a client-side route, not included here strings.HasPrefix(req.URL.Path, "/session") || strings.HasPrefix(req.URL.Path, "/tui") || strings.HasPrefix(req.URL.Path, "/config") || diff --git a/internal/config/config.go b/internal/config/config.go index c5b14c8e8..84dc5256d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -503,6 +503,9 @@ type PBSInstance struct { // Agent tracking Source string // "agent" or "script" - how this node was registered (empty = legacy/manual) DisableCeph bool // Disable Ceph status polling for this instance + + // Datastore exclusion (for unmounted/removable datastores that cause log noise) + ExcludeDatastores []string } // PMGInstance represents a Proxmox Mail Gateway connection diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 327abd6cb..abb51fd61 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -7374,6 +7374,56 @@ func copyFloatPointer(src *float64) *float64 { return &val } +// matchesDatastoreExclude checks if a datastore name matches any exclusion pattern. +// Patterns can be exact names or wildcards (* for any characters). +// Examples: "exthdd*" matches "exthdd1500gb", "*backup*" matches "my-backup-store" +func matchesDatastoreExclude(datastoreName string, excludePatterns []string) bool { + if len(excludePatterns) == 0 { + return false + } + + for _, pattern := range excludePatterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue + } + + // Contains pattern: *substring* + if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") && len(pattern) > 2 { + substring := strings.ToLower(pattern[1 : len(pattern)-1]) + if strings.Contains(strings.ToLower(datastoreName), substring) { + return true + } + continue + } + + // Suffix pattern: *suffix + if strings.HasPrefix(pattern, "*") && len(pattern) > 1 { + suffix := strings.ToLower(pattern[1:]) + if strings.HasSuffix(strings.ToLower(datastoreName), suffix) { + return true + } + continue + } + + // Prefix pattern: prefix* + if strings.HasSuffix(pattern, "*") && len(pattern) > 1 { + prefix := strings.ToLower(pattern[:len(pattern)-1]) + if strings.HasPrefix(strings.ToLower(datastoreName), prefix) { + return true + } + continue + } + + // Exact match (case-insensitive) + if strings.EqualFold(pattern, datastoreName) { + return true + } + } + + return false +} + // pollPBSInstance polls a single PBS instance func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, client *pbs.Client) { defer recoverFromPanic(fmt.Sprintf("pollPBSInstance-%s", instanceName)) @@ -7537,6 +7587,14 @@ func (m *Monitor) pollPBSInstance(ctx context.Context, instanceName string, clie Msg("Got PBS datastores") for _, ds := range datastores { + // Skip excluded datastores (for removable/unmounted datastores) + if matchesDatastoreExclude(ds.Store, instanceCfg.ExcludeDatastores) { + log.Debug(). + Str("instance", instanceName). + Str("datastore", ds.Store). + Msg("Skipping excluded datastore") + continue + } total := ds.Total if total == 0 && ds.TotalSpace > 0 { total = ds.TotalSpace diff --git a/internal/monitoring/pbs_convert_test.go b/internal/monitoring/pbs_convert_test.go index f403d215f..f524fe749 100644 --- a/internal/monitoring/pbs_convert_test.go +++ b/internal/monitoring/pbs_convert_test.go @@ -7,6 +7,63 @@ import ( "github.com/rcourtman/pulse-go-rewrite/pkg/pbs" ) +func TestMatchesDatastoreExclude(t *testing.T) { + tests := []struct { + name string + datastoreName string + patterns []string + expected bool + }{ + // Empty patterns + {"empty patterns returns false", "exthdd1500gb", nil, false}, + {"empty slice returns false", "exthdd1500gb", []string{}, false}, + + // Exact match (case-insensitive) + {"exact match", "exthdd1500gb", []string{"exthdd1500gb"}, true}, + {"exact match case insensitive", "ExtHDD1500GB", []string{"exthdd1500gb"}, true}, + {"exact match no match", "exthdd1500gb", []string{"backup"}, false}, + + // Prefix pattern (name*) + {"prefix pattern match", "exthdd1500gb", []string{"ext*"}, true}, + {"prefix pattern match 2", "backup-external", []string{"backup*"}, true}, + {"prefix pattern no match", "internal-storage", []string{"ext*"}, false}, + {"prefix pattern case insensitive", "EXTHDD1500GB", []string{"ext*"}, true}, + + // Suffix pattern (*name) + {"suffix pattern match", "my-external-hdd", []string{"*hdd"}, true}, + {"suffix pattern match 2", "backup-store", []string{"*store"}, true}, + {"suffix pattern no match", "hdd-backup", []string{"*store"}, false}, + {"suffix pattern case insensitive", "MY-EXTERNAL-HDD", []string{"*hdd"}, true}, + + // Contains pattern (*name*) + {"contains pattern match", "my-external-hdd", []string{"*external*"}, true}, + {"contains pattern match middle", "backup-removable-drive", []string{"*removable*"}, true}, + {"contains pattern no match", "internal-drive", []string{"*external*"}, false}, + {"contains pattern case insensitive", "BACKUP-REMOVABLE-DRIVE", []string{"*removable*"}, true}, + + // Multiple patterns (any match) + {"multiple patterns first match", "exthdd1500gb", []string{"backup*", "ext*"}, true}, + {"multiple patterns second match", "backup-drive", []string{"ext*", "backup*"}, true}, + {"multiple patterns no match", "internal", []string{"ext*", "backup*"}, false}, + + // Edge cases + {"empty pattern in list", "exthdd", []string{"", "ext*"}, true}, + {"whitespace pattern", "exthdd", []string{" ", "ext*"}, true}, + {"pattern with whitespace", "exthdd", []string{" ext* "}, true}, + {"single star", "anything", []string{"*"}, false}, // Single star doesn't match (needs prefix/suffix) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchesDatastoreExclude(tt.datastoreName, tt.patterns) + if result != tt.expected { + t.Errorf("matchesDatastoreExclude(%q, %v) = %t, want %t", + tt.datastoreName, tt.patterns, result, tt.expected) + } + }) + } +} + func TestConvertPBSSnapshots(t *testing.T) { t.Run("empty input returns empty slice", func(t *testing.T) { result := convertPBSSnapshots("pbs-1", "backup-store", "ns1", nil)