Pulse/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx
rcourtman 27f2038dab Add per-node temperature monitoring and fix critical config update bug
This commit implements per-node temperature monitoring control and fixes a critical
bug where partial node updates were destroying existing configuration.

Backend changes:
- Add TemperatureMonitoringEnabled field (*bool) to PVEInstance, PBSInstance, and PMGInstance
- Update monitor.go to check per-node temperature setting with global fallback
- Convert all NodeConfigRequest boolean fields to *bool pointers
- Add nil checks in HandleUpdateNode to prevent overwriting unmodified fields
- Fix critical bug where partial updates zeroed out MonitorVMs, MonitorContainers, etc.
- Update NodeResponse, NodeFrontend, and StateSnapshot to include temperature setting
- Fix HandleAddNode and test connection handlers to use pointer-based boolean fields

Frontend changes:
- Add temperatureMonitoringEnabled to Node interface and config types
- Create per-node temperature monitoring toggle handler with optimistic updates
- Update NodeModal to wire up per-node temperature toggle
- Add isTemperatureMonitoringEnabled helper to check effective monitoring state
- Update ConfiguredNodeTables to show/hide temperature badge based on monitoring state
- Update NodeSummaryTable to conditionally show temperature column
- Pass globalTemperatureMonitoringEnabled prop through component tree

The critical bug fix ensures that when updating a single field (like temperature
monitoring), the backend only modifies that specific field instead of zeroing out
all other boolean configuration fields.
2025-11-05 14:11:53 +00:00

117 lines
3.9 KiB
TypeScript

import { Component, createSignal, createEffect, createMemo, onMount, onCleanup } from 'solid-js';
import { useWebSocket } from '@/App';
import { NodeSummaryTable } from './NodeSummaryTable';
import type { Node, VM, Container, Storage } from '@/types/api';
interface UnifiedNodeSelectorProps {
currentTab: 'dashboard' | 'storage' | 'backups';
globalTemperatureMonitoringEnabled?: boolean;
onNodeSelect?: (nodeId: string | null, nodeType: 'pve' | 'pbs' | null) => void;
onNamespaceSelect?: (namespace: string) => void;
nodes?: Node[];
filteredVms?: VM[];
filteredContainers?: Container[];
filteredStorage?: Storage[];
searchTerm?: string;
}
export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (props) => {
const { state } = useWebSocket();
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
// Handle ESC key to deselect node
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && selectedNode()) {
setSelectedNode(null);
props.onNodeSelect?.(null, null);
}
};
onMount(() => {
document.addEventListener('keydown', handleKeyDown);
});
onCleanup(() => {
document.removeEventListener('keydown', handleKeyDown);
});
// Reset selection when tab changes
createEffect(() => {
props.currentTab;
setSelectedNode(null);
});
// No longer syncing with search term - selection is independent
// This allows users to select a node AND search within it
// Calculate backup counts for nodes and PBS instances
const pveBackupsState = () => state.backups?.pve ?? state.pveBackups;
const pbsBackupsState = () => state.backups?.pbs ?? state.pbsBackups;
const backupCounts = createMemo(() => {
const counts: Record<string, number> = {};
// Count PVE backups and snapshots by node instance (to handle duplicate hostnames)
const nodes = props.nodes || state.nodes;
if (nodes) {
nodes.forEach((node) => {
let count = 0;
// Count storage backups (excluding PBS backups which are counted separately)
const pveBackups = pveBackupsState();
if (pveBackups?.storageBackups) {
count += pveBackups.storageBackups.filter(
(b) => b.instance === node.instance && b.node === node.name && !b.isPBS,
).length;
}
// Count snapshots
if (pveBackups?.guestSnapshots) {
count += pveBackups.guestSnapshots.filter((s) => s.instance === node.instance && s.node === node.name).length;
}
counts[node.id] = count;
});
}
// Count PBS backups by instance
const pbsBackups = pbsBackupsState();
if (state.pbs && pbsBackups) {
state.pbs.forEach((pbs) => {
counts[pbs.name] = pbsBackups.filter((b) => b.instance === pbs.name).length || 0;
});
}
return counts;
});
const handleNodeClick = (nodeId: string, nodeType: 'pve' | 'pbs') => {
// Toggle selection
if (selectedNode() === nodeId) {
setSelectedNode(null);
props.onNodeSelect?.(null, null);
} else {
setSelectedNode(nodeId);
props.onNodeSelect?.(nodeId, nodeType);
}
};
// Parent components now handle conditional rendering, so we can render directly
const nodes = createMemo(() => props.nodes || state.nodes || []);
return (
<div class="space-y-2 mb-4">
<NodeSummaryTable
nodes={nodes()}
pbsInstances={props.currentTab === 'backups' ? state.pbs : undefined}
vms={state.vms} // Always use unfiltered data for counts
containers={state.containers} // Always use unfiltered data for counts
storage={state.storage} // Always use unfiltered data for counts
backupCounts={backupCounts()}
currentTab={props.currentTab}
selectedNode={selectedNode()}
globalTemperatureMonitoringEnabled={props.globalTemperatureMonitoringEnabled}
onNodeClick={handleNodeClick}
/>
</div>
);
};