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:
rcourtman
2026-01-14 12:26:18 +00:00
parent 3e74e689cd
commit 9b49d3171d
8 changed files with 161 additions and 32 deletions
+2
View File
@@ -1,5 +1,7 @@
package main
// rebuild trigger
import (
"context"
"fmt"
+1 -4
View File
@@ -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,
+5
View File
@@ -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)
+33 -26
View File
@@ -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
+2 -2
View File
@@ -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") ||
+3
View File
@@ -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
+58
View File
@@ -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
+57
View File
@@ -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)