mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
feat(pbs): add datastore exclusion to reduce PBS log noise
Users with removable/unmounted datastores (e.g., external HDDs for offline backup) experienced excessive PBS log entries because Pulse was querying all datastores including unavailable ones. Added `excludeDatastores` field to PBS node configuration that accepts patterns to exclude specific datastores from monitoring: - Exact names: "exthdd1500gb" - Prefix patterns: "ext*" - Suffix patterns: "*hdd" - Contains patterns: "*removable*" Pattern matching is case-insensitive. Fixes #1105
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
package main
|
||||
|
||||
// rebuild trigger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user