fix: improve discovery performance and reliability

Discovery Fixes:
- Always update cache even when scan finds no servers (prevents stale data)
- Remove automatic re-add of deleted nodes to discovery (was causing confusion)
- Optimize Docker subnet scanning from 762 IPs to 254 IPs (3x faster)
- Add getHostSubnetFromGateway() to detect host network from container

Frontend Type Fixes:
- Fix ThresholdsTable editScope type errors
- Fix SnapshotAlertConfig index signature
- Remove unused variable in Settings.tsx

These changes make discovery faster, more reliable, and fix the issue where
deleted nodes would persist in the discovery cache or immediately reappear.
This commit is contained in:
rcourtman 2025-10-18 22:59:40 +00:00
parent 2045bcfdd6
commit b640347a78
9 changed files with 82 additions and 85 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<meta name="theme-color" content="#000000" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<title>Pulse</title>
<script type="module" crossorigin src="/assets/index-BFQdFd5E.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-VFB0BYLW.css">
<script type="module" crossorigin src="/assets/index-BTYq3hC4.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C-bZ849w.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View file

@ -69,7 +69,7 @@ export interface Resource {
clusterName?: string;
isClusterMember?: boolean;
delaySeconds?: number;
editScope?: 'snapshot';
editScope?: 'snapshot' | 'backup';
isEnabled?: boolean;
toggleEnabled?: () => void;
toggleTitleEnabled?: string;

View file

@ -821,6 +821,14 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
};
});
const snapshotFactoryDefaultsRecord = createMemo(() => {
const factory = snapshotFactoryConfig();
return {
'warning days': factory.warningDays ?? DEFAULT_SNAPSHOT_WARNING,
'critical days': factory.criticalDays ?? DEFAULT_SNAPSHOT_CRITICAL,
};
});
const backupFactoryConfig = () =>
props.backupFactoryDefaults ?? {
enabled: false,
@ -869,13 +877,13 @@ const dockerContainersGroupedByHost = createMemo<Record<string, Resource[]>>((pr
};
});
const backupFactoryDefaultsRecord = createMemo(() => {
const factory = backupFactoryConfig();
return {
'warning days': factory.warningDays ?? DEFAULT_BACKUP_WARNING,
'critical days': factory.criticalDays ?? DEFAULT_BACKUP_CRITICAL,
};
});
const backupFactoryDefaultsRecord = createMemo(() => {
const factory = backupFactoryConfig();
return {
'warning days': factory.warningDays ?? DEFAULT_BACKUP_WARNING,
'critical days': factory.criticalDays ?? DEFAULT_BACKUP_CRITICAL,
};
});
const snapshotOverridesCount = createMemo(() => {
const current = props.snapshotDefaults();
@ -2204,7 +2212,7 @@ const snapshotOverridesCount = createMemo(() => {
enabled: !prev.enabled,
}))
}
factoryDefaults={snapshotFactoryConfig()}
factoryDefaults={snapshotFactoryDefaultsRecord()}
onResetDefaults={() => {
if (props.resetSnapshotDefaults) {
props.resetSnapshotDefaults();

View file

@ -319,7 +319,6 @@ const Settings: Component<SettingsProps> = (props) => {
const pveBackupsState = () => state.backups?.pve ?? state.pveBackups;
const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups;
const pmgBackupsState = () => state.backups?.pmg ?? state.pmgBackups;
// Keep tab state in sync with URL and handle /settings redirect without flicker
createEffect(

View file

@ -1907,41 +1907,11 @@ func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request
}
// Immediately trigger discovery scan BEFORE reloading monitor
// This way we can get the deleted node's info for immediate discovery
// Capture node type for cleanup
var deletedNodeType string = nodeType
// deletedNodeHost already captured before removal when available
// Extract IP and port from the host URL for targeted discovery
var targetIP string
var targetPort int
if deletedNodeHost != "" {
// Parse the host URL to get IP and port
hostURL := deletedNodeHost
hostURL = strings.TrimPrefix(hostURL, "https://")
hostURL = strings.TrimPrefix(hostURL, "http://")
parts := strings.Split(hostURL, ":")
if len(parts) > 0 {
targetIP = parts[0]
if len(parts) > 1 {
if _, err := fmt.Sscanf(parts[1], "%d", &targetPort); err != nil {
log.Warn().Err(err).Str("host", deletedNodeHost).Msg("Failed to parse port from host; using default")
if deletedNodeType == "pve" {
targetPort = 8006
} else {
targetPort = 8007
}
}
} else {
if deletedNodeType == "pve" {
targetPort = 8006
} else {
targetPort = 8007
}
}
}
}
// Reload monitor with new configuration
if h.reloadFunc != nil {
if err := h.reloadFunc(); err != nil {
@ -1963,38 +1933,8 @@ func (h *ConfigHandlers) HandleDeleteNode(w http.ResponseWriter, r *http.Request
})
log.Info().Msg("Broadcasted node deletion event")
// If we know the deleted node's details, immediately add it to discovered list
if targetIP != "" && targetPort > 0 {
// Create a synthetic discovery result with just the deleted node
immediateResult := map[string]interface{}{
"servers": []map[string]interface{}{
{
"ip": targetIP,
"port": targetPort,
"type": deletedNodeType,
"version": "Unknown",
"hostname": targetIP,
},
},
"errors": []string{},
"timestamp": time.Now().Unix(),
"immediate": true, // Flag to indicate this is immediate, not from a full scan
}
// Immediately broadcast the deleted node as discovered
h.wsHub.BroadcastMessage(websocket.Message{
Type: "discovery_update",
Data: immediateResult,
Timestamp: time.Now().Format(time.RFC3339),
})
log.Info().
Str("ip", targetIP).
Int("port", targetPort).
Str("type", deletedNodeType).
Msg("Immediately added deleted node to discovery")
}
// Schedule a full discovery scan in the background
// Trigger a full discovery scan in the background to update the discovery cache
// This ensures the next time discovery modal is opened, it shows fresh results
go func() {
// Short delay to let the monitor stabilize
time.Sleep(500 * time.Millisecond)

View file

@ -167,8 +167,8 @@ func (s *Service) performScan() {
Msg("Discovery scan incomplete but found some servers")
}
// Update cache even with partial results
if result != nil && len(result.Servers) > 0 {
// Always update cache with results (even if empty) to prevent stale data
if result != nil {
s.cache.mu.Lock()
s.cache.result = result
s.cache.updated = time.Now()

View file

@ -72,18 +72,27 @@ func (s *Scanner) DiscoverServersWithCallback(ctx context.Context, subnet string
if subnet == "" || subnet == "auto" {
// Check if we're in Docker (detected subnet is Docker network)
autoDetected := s.getLocalSubnet()
if autoDetected != nil && strings.HasPrefix(autoDetected.String(), "172.17.") {
log.Info().Msg("Running in Docker - scanning common home/office networks")
// In Docker, scan common subnets instead
ipNets = s.getCommonSubnets()
if autoDetected != nil && (strings.HasPrefix(autoDetected.String(), "172.17.") || strings.HasPrefix(autoDetected.String(), "172.1")) {
log.Info().Msg("Running in Docker - detecting host network from gateway")
// Try to detect the host's network from the default gateway
if hostSubnet := s.getHostSubnetFromGateway(); hostSubnet != nil {
ipNets = []*net.IPNet{hostSubnet}
log.Info().Str("detected", hostSubnet.String()).Msg("Detected host subnet from Docker gateway")
} else {
// Fallback: scan only most common subnet (192.168.0.0/24)
log.Info().Msg("Could not detect host subnet - scanning 192.168.0.0/24")
_, defaultNet, _ := net.ParseCIDR("192.168.0.0/24")
ipNets = []*net.IPNet{defaultNet}
}
} else if autoDetected != nil {
// Use auto-detected subnet
ipNets = []*net.IPNet{autoDetected}
log.Info().Str("detected", autoDetected.String()).Msg("Auto-detected local subnet")
} else {
// Fallback to common subnets
log.Info().Msg("Auto-detection failed - scanning common networks")
ipNets = s.getCommonSubnets()
// Fallback to most common subnet
log.Info().Msg("Auto-detection failed - scanning 192.168.0.0/24")
_, defaultNet, _ := net.ParseCIDR("192.168.0.0/24")
ipNets = []*net.IPNet{defaultNet}
}
} else {
// Parse provided subnet
@ -657,6 +666,47 @@ func (s *Scanner) getLocalSubnet() *net.IPNet {
return defaultNet
}
// getHostSubnetFromGateway detects the host network by examining the default gateway
// This is useful when running in Docker to detect the actual host's network
func (s *Scanner) getHostSubnetFromGateway() *net.IPNet {
interfaces, err := net.Interfaces()
if err != nil {
return nil
}
// Find the default gateway by checking routes
// In Docker, the gateway IP usually ends in .1 and is on the Docker bridge network
for _, iface := range interfaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil {
// Check if this looks like a Docker bridge network (172.17.x.x or similar)
ipStr := ipNet.IP.String()
if strings.HasPrefix(ipStr, "172.17.") || strings.HasPrefix(ipStr, "172.1") {
// Gateway is typically .1 in the same subnet
// Try to derive the host network: gateway .1 -> likely host is 192.168.x.0/24
// We'll try the .1 address as the gateway and ping common host subnets
// For now, just return the most common subnet
// A more sophisticated approach would parse /proc/net/route
_, hostNet, _ := net.ParseCIDR("192.168.0.0/24")
return hostNet
}
}
}
}
return nil
}
// getCommonSubnets returns a list of common home/office network subnets
func (s *Scanner) getCommonSubnets() []*net.IPNet {
// Ordered by likelihood - most common first for faster results