refactor: use search field for node/PBS filtering instead of duplicate logic

- Replace separate selectedNode/selectedPBSInstance state with search-based filtering
- Add node: prefix to search when node/PBS cards clicked
- Lock search field (greyed out) when auto-populated from node selection
- Reuse existing search filter logic instead of duplicating filtering code
- Clear node filter updates search field instead of separate state
- Much cleaner implementation leveraging existing search capabilities
This commit is contained in:
Pulse Monitor
2025-08-18 17:45:02 +00:00
parent 2f8b176d80
commit 67c4122f95
2 changed files with 103 additions and 36 deletions
@@ -44,7 +44,28 @@ const UnifiedBackups: Component = () => {
const [selectedDateRange, setSelectedDateRange] = createSignal<{ start: number; end: number } | null>(null);
const [chartTimeRange, setChartTimeRange] = createSignal(30);
const [tooltip, setTooltip] = createSignal<{ text: string; x: number; y: number } | null>(null);
const [selectedPBSInstance, setSelectedPBSInstance] = createSignal<string | null>(null);
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
// Extract PBS instance from search term
const selectedPBSInstance = createMemo(() => {
const search = searchTerm();
const match = search.match(/node:(\S+)/);
if (match && state.pbs?.some(pbs => pbs.name === match[1])) {
return match[1];
}
return null;
});
// Auto-set backup type filter when PBS instance is selected
createEffect(() => {
const pbsInstance = selectedPBSInstance();
if (pbsInstance) {
setBackupTypeFilter('remote');
} else if (!isSearchLocked()) {
setBackupTypeFilter('all');
}
});
const [showFilters, setShowFilters] = createLocalStorageBooleanSignal(
STORAGE_KEYS.BACKUPS_SHOW_FILTERS,
false // Default to collapsed
@@ -54,18 +75,6 @@ const UnifiedBackups: Component = () => {
false // Default to absolute time
);
// Auto-set backup type filter to 'remote' when a PBS instance is selected
createEffect(() => {
const pbsInstance = selectedPBSInstance();
if (pbsInstance) {
// When a PBS instance is selected, only show remote backups
setBackupTypeFilter('remote');
} else {
// When deselected, reset to show all
setBackupTypeFilter('all');
}
});
// Helper functions
const getDaySuffix = (day: number) => {
if (day >= 11 && day <= 13) return 'th';
@@ -489,6 +498,7 @@ const UnifiedBackups: Component = () => {
// Reset filters
const resetFilters = () => {
setSearchTerm('');
setIsSearchLocked(false);
setTypeFilter('all');
setBackupTypeFilter('all');
setSortKey('backupTime');
@@ -690,7 +700,10 @@ const UnifiedBackups: Component = () => {
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Showing only backups from PBS server: <strong>{selectedPBSInstance()}</strong></span>
<button
onClick={() => setSelectedPBSInstance(null)}
onClick={() => {
setSearchTerm('');
setIsSearchLocked(false);
}}
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Show all backups
@@ -703,10 +716,26 @@ const UnifiedBackups: Component = () => {
<div
class="flex-1 min-w-[250px] cursor-pointer transition-transform hover:scale-[1.02]"
onClick={() => {
// Toggle selection - click again to deselect
setSelectedPBSInstance(
selectedPBSInstance() === instance.name ? null : instance.name
);
const currentSearch = searchTerm();
const nodeFilter = `node:${instance.name}`;
// Check if this PBS filter is already in the search
if (currentSearch.includes(nodeFilter)) {
// Remove the PBS filter
setSearchTerm(currentSearch.replace(nodeFilter, '').trim().replace(/,\s*,/g, ',').replace(/^,|,$/g, ''));
setIsSearchLocked(false);
} else {
// Clear any existing node: filters and add the new one
const cleanedSearch = currentSearch.replace(/node:\S+/g, '').trim().replace(/,\s*,/g, ',').replace(/^,|,$/g, '');
const newSearch = cleanedSearch ? `${cleanedSearch}, ${nodeFilter}` : nodeFilter;
setSearchTerm(newSearch);
setIsSearchLocked(true);
// Expand filters if collapsed
if (!showFilters()) {
setShowFilters(true);
}
}
}}
>
<PBSCard
@@ -1152,10 +1181,16 @@ const UnifiedBackups: Component = () => {
type="text"
placeholder="Search VMID, Name, Node, Storage (use ',' for OR)"
value={searchTerm()}
onInput={(e) => setSearchTerm(e.currentTarget.value)}
class="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
onInput={(e) => {
if (!isSearchLocked()) {
setSearchTerm(e.currentTarget.value);
}
}}
disabled={isSearchLocked()}
class={`w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all
${isSearchLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
@@ -24,7 +24,17 @@ type StatusMode = 'all' | 'running' | 'stopped';
export function Dashboard(props: DashboardProps) {
const { connected, activeAlerts, initialDataReceived } = useWebSocket();
const [search, setSearch] = createSignal('');
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
// Extract selected node from search term
const selectedNode = createMemo(() => {
const searchStr = search();
const match = searchStr.match(/node:(\S+)/);
if (match && props.nodes.some(node => node.name === match[1])) {
return match[1];
}
return null;
});
// Initialize from localStorage with proper type checking
const storedViewMode = localStorage.getItem('dashboardViewMode');
@@ -131,6 +141,7 @@ export function Dashboard(props: DashboardProps) {
if (search().trim() || sortKey() !== 'vmid' || sortDirection() !== 'asc') {
// Clear search and reset filters
setSearch('');
setIsSearchLocked(false);
setSortKey('vmid');
setSortDirection('asc');
@@ -175,11 +186,6 @@ export function Dashboard(props: DashboardProps) {
const filteredGuests = createMemo(() => {
let guests = allGuests();
// Filter by selected node
if (selectedNode()) {
guests = guests.filter(g => g.node === selectedNode());
}
// Filter by type
if (viewMode() === 'vm') {
guests = guests.filter(g => g.type === 'qemu');
@@ -329,7 +335,10 @@ export function Dashboard(props: DashboardProps) {
<div class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
<span>Showing guests for node: <strong>{selectedNode()}</strong></span>
<button
onClick={() => setSelectedNode(null)}
onClick={() => {
setSearch('');
setIsSearchLocked(false);
}}
class="text-blue-600 dark:text-blue-400 hover:underline"
>
Clear filter
@@ -346,16 +355,32 @@ export function Dashboard(props: DashboardProps) {
<div
class="flex-1 min-w-[250px] cursor-pointer transition-transform hover:scale-[1.02]"
onClick={() => {
// Toggle selection - click again to deselect
setSelectedNode(
selectedNode() === node.name ? null : node.name
);
const currentSearch = search();
const nodeFilter = `node:${node.name}`;
// Check if this node filter is already in the search
if (currentSearch.includes(nodeFilter)) {
// Remove the node filter
setSearch(currentSearch.replace(nodeFilter, '').trim().replace(/,\s*,/g, ',').replace(/^,|,$/g, ''));
setIsSearchLocked(false);
} else {
// Clear any existing node: filters and add the new one
const cleanedSearch = currentSearch.replace(/node:\w+/g, '').trim().replace(/,\s*,/g, ',').replace(/^,|,$/g, '');
const newSearch = cleanedSearch ? `${cleanedSearch}, ${nodeFilter}` : nodeFilter;
setSearch(newSearch);
setIsSearchLocked(true);
// Expand filters if collapsed
if (!showFilters()) {
setShowFilters(true);
}
}
}}
>
<ComponentErrorBoundary name="NodeCard">
<NodeCard
node={node}
isSelected={selectedNode() === node.name}
isSelected={search().includes(`node:${node.name}`)}
/>
</ComponentErrorBoundary>
</div>
@@ -442,10 +467,16 @@ export function Dashboard(props: DashboardProps) {
type="text"
placeholder="Search: name, jellyfin,plex, or cpu>80"
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
class="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
onInput={(e) => {
if (!isSearchLocked()) {
setSearch(e.currentTarget.value);
}
}}
disabled={isSearchLocked()}
class={`w-full pl-9 pr-9 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all"
focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all
${isSearchLocked() ? 'opacity-60 cursor-not-allowed' : ''}`}
title="Search guests or use filters like cpu>80"
/>
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -491,6 +522,7 @@ export function Dashboard(props: DashboardProps) {
<button
onClick={() => {
setSearch('');
setIsSearchLocked(false);
setSortKey('vmid');
setSortDirection('asc');
setViewMode('all');