From 597e695f0ecb31a56b6867d628be18700fbad6c2 Mon Sep 17 00:00:00 2001 From: Pulse Monitor Date: Fri, 29 Aug 2025 10:39:45 +0000 Subject: [PATCH] feat: improve UI consistency and visual feedback - Remove redundant status column from node tables (green/red dot is sufficient) - Make stopped guests more visually distinct (red indicator + dimmed row) - Improve node selection behavior (visual only, no search field modification) - Fix hover vs selected visual states for clarity - Standardize hover highlighting across all tables (hover:bg-gray-50 dark:hover:bg-gray-700/30) - Add better visual separation for tooltips (tags and backups) - Remove unnecessary rebuild.sh script (only needed at release time) - Fix backend-watch.sh to use correct PORT environment variable - Update vite.config.ts to proxy to correct backend port (7656) - Clean up console.log debugging statements --- .../components/Alerts/EmailProviderSelect.tsx | 2 +- .../src/components/Alerts/WebhookConfig.tsx | 2 +- .../src/components/Backups/UnifiedBackups.tsx | 10 +-- .../src/components/Dashboard/Dashboard.tsx | 30 ++++--- .../components/Dashboard/DashboardFilter.tsx | 10 +-- .../src/components/Dashboard/GuestRow.tsx | 7 +- .../src/components/Dashboard/TagBadges.tsx | 4 +- .../components/Settings/DiscoveryModal.tsx | 2 +- .../src/components/Storage/Storage.tsx | 2 +- .../components/shared/NodeSummaryTable.tsx | 2 +- .../src/components/shared/PBSNodeTable.tsx | 31 +------ .../src/components/shared/PVENodeTable.tsx | 84 ++++--------------- .../src/components/shared/Tooltip.tsx | 4 +- .../components/shared/UnifiedNodeSelector.tsx | 3 + frontend-modern/src/styles/animations.css | 19 ++++- frontend-modern/vite.config.ts | 4 +- rebuild.sh | 17 ---- 17 files changed, 79 insertions(+), 154 deletions(-) delete mode 100755 rebuild.sh diff --git a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx index e99ffa0b1..a03315599 100644 --- a/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx +++ b/frontend-modern/src/components/Alerts/EmailProviderSelect.tsx @@ -90,7 +90,7 @@ export function EmailProviderSelect(props: EmailProviderSelectProps) { class={`p-3 text-left rounded-lg border transition-all ${ props.config.provider === provider.name ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50' + : 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30' }`} >
diff --git a/frontend-modern/src/components/Alerts/WebhookConfig.tsx b/frontend-modern/src/components/Alerts/WebhookConfig.tsx index eaaf63fe7..341765617 100644 --- a/frontend-modern/src/components/Alerts/WebhookConfig.tsx +++ b/frontend-modern/src/components/Alerts/WebhookConfig.tsx @@ -220,7 +220,7 @@ export function WebhookConfig(props: WebhookConfigProps) { class={`p-3 text-left rounded-lg border transition-all ${ formData().service === service ? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20' - : 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/50' + : 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700/30' }`} >
diff --git a/frontend-modern/src/components/Backups/UnifiedBackups.tsx b/frontend-modern/src/components/Backups/UnifiedBackups.tsx index 29527200e..79082c6bc 100644 --- a/frontend-modern/src/components/Backups/UnifiedBackups.tsx +++ b/frontend-modern/src/components/Backups/UnifiedBackups.tsx @@ -956,7 +956,7 @@ const UnifiedBackups: Component = () => { return ( { @@ -1682,7 +1682,7 @@ const UnifiedBackups: Component = () => { {(item) => ( - + {item.name || '-'} @@ -1726,14 +1726,14 @@ const UnifiedBackups: Component = () => { {item.backupType === 'snapshot' ? 'Snapshot' : item.backupType === 'local' ? 'PVE' : 'PBS'} - + - + @@ -1850,7 +1850,7 @@ const UnifiedBackups: Component = () => { class="fixed z-[9999] px-3 py-2 text-sm bg-black text-white rounded-lg shadow-xl pointer-events-none" style={{ left: `${tooltip()!.x - 75}px`, - top: `${tooltip()!.y}px`, + top: `${tooltip()!.y - 35}px`, "max-width": "200px", "white-space": "pre-line", "font-family": "system-ui, -apple-system, sans-serif" diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 1f37c5b98..dd0de8f59 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -27,6 +27,7 @@ export function Dashboard(props: DashboardProps) { const { connected, activeAlerts, initialDataReceived } = useWebSocket(); const [search, setSearch] = createSignal(''); const [isSearchLocked, setIsSearchLocked] = createSignal(false); + const [selectedNode, setSelectedNode] = createSignal(null); // Initialize from localStorage with proper type checking const storedViewMode = localStorage.getItem('dashboardViewMode'); @@ -192,6 +193,14 @@ export function Dashboard(props: DashboardProps) { const filteredGuests = createMemo(() => { let guests = allGuests(); + // Filter by selected node + const node = selectedNode(); + console.log('Filtering guests - selected node:', node, 'total guests:', guests.length); + if (node) { + guests = guests.filter(g => g.node === node); + console.log('After node filter:', guests.length); + } + // Filter by type if (viewMode() === 'vm') { guests = guests.filter(g => g.type === 'qemu'); @@ -388,18 +397,15 @@ export function Dashboard(props: DashboardProps) { const handleNodeSelect = (nodeId: string | null, nodeType: 'pve' | 'pbs' | null) => { - if (nodeId && nodeType === 'pve') { - // Set search to filter by node - const nodeFilter = `node:${nodeId}`; - setSearch(nodeFilter); - setIsSearchLocked(true); - if (!showFilters()) { + console.log('handleNodeSelect called:', nodeId, nodeType); + // Track selected node for filtering + if (nodeType === 'pve' || nodeType === null) { + setSelectedNode(nodeId); + console.log('Set selected node to:', nodeId); + // Show filters if a node is selected + if (nodeId && !showFilters()) { setShowFilters(true); } - } else { - // Clear node filter - setSearch(''); - setIsSearchLocked(false); } }; @@ -498,7 +504,7 @@ export function Dashboard(props: DashboardProps) { return ( { @@ -817,7 +823,7 @@ export function Dashboard(props: DashboardProps) { | - + {totalStats().stopped} stopped
diff --git a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx index 155e29564..e87f3c79d 100644 --- a/frontend-modern/src/components/Dashboard/DashboardFilter.tsx +++ b/frontend-modern/src/components/Dashboard/DashboardFilter.tsx @@ -27,16 +27,10 @@ export const DashboardFilter: Component = (props) => { type="text" placeholder="Search by name, cpu>80, memory<20, tags:prod, node:pve1" value={props.search()} - onInput={(e) => { - if (!props.isSearchLocked()) { - props.setSearch(e.currentTarget.value); - } - }} - disabled={props.isSearchLocked()} + onInput={(e) => props.setSearch(e.currentTarget.value)} class={`w-full pl-9 pr-9 py-1.5 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 - ${props.isSearchLocked() ? 'opacity-60 cursor-not-allowed' : ''}`} + focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:focus:border-blue-400 outline-none transition-all`} title="Search guests or use filters like cpu>80" /> diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 197c8cc1b..b71775a79 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -86,8 +86,9 @@ export function GuestRow(props: GuestRowProps) { ? 'bg-red-50 dark:bg-red-950/30' : 'bg-yellow-50 dark:bg-yellow-950/20') : ''; - const defaultHover = props.alertStyles?.hasAlert ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700'; - return `${base} ${hover} ${defaultHover} ${alertBg}`; + const defaultHover = props.alertStyles?.hasAlert ? '' : 'hover:bg-gray-50 dark:hover:bg-gray-700/30'; + const stoppedDimming = !isRunning() ? 'opacity-60' : ''; + return `${base} ${hover} ${defaultHover} ${alertBg} ${stoppedDimming}`; }); // Get first cell styling with left border for alerts @@ -108,7 +109,7 @@ export function GuestRow(props: GuestRowProps) {
{/* Status indicator */} {/* Name - clickable if custom URL is set */} diff --git a/frontend-modern/src/components/Dashboard/TagBadges.tsx b/frontend-modern/src/components/Dashboard/TagBadges.tsx index c687cc158..e92590e45 100644 --- a/frontend-modern/src/components/Dashboard/TagBadges.tsx +++ b/frontend-modern/src/components/Dashboard/TagBadges.tsx @@ -93,7 +93,7 @@ export const TagBadges: Component = (props) => { class="fixed px-2 py-1 bg-gray-800 dark:bg-gray-700 text-white text-xs rounded shadow-lg pointer-events-none" style={{ left: `${tooltipPos()!.x}px`, - top: `${tooltipPos()!.y - 28}px`, + top: `${tooltipPos()!.y - 40}px`, transform: 'translateX(-50%)', 'z-index': '999999', }} @@ -114,7 +114,7 @@ export const TagBadges: Component = (props) => { class="fixed px-2 py-1 text-xs rounded shadow-lg pointer-events-none" style={{ left: `${tooltipPos()!.x}px`, - top: `${tooltipPos()!.y - 24}px`, + top: `${tooltipPos()!.y - 35}px`, transform: 'translateX(-50%)', 'background-color': colors.bg, 'color': colors.text, diff --git a/frontend-modern/src/components/Settings/DiscoveryModal.tsx b/frontend-modern/src/components/Settings/DiscoveryModal.tsx index de3f3382a..8f89f2f58 100644 --- a/frontend-modern/src/components/Settings/DiscoveryModal.tsx +++ b/frontend-modern/src/components/Settings/DiscoveryModal.tsx @@ -325,7 +325,7 @@ export const DiscoveryModal: Component = (props) => { {(server) => (
handleAddServer(server)} >
diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index d04257be8..6afaab51a 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -328,7 +328,7 @@ const Storage: Component = () => { : 'p-0.5 px-1.5'; return ( - +
diff --git a/frontend-modern/src/components/shared/NodeSummaryTable.tsx b/frontend-modern/src/components/shared/NodeSummaryTable.tsx index ccf7697b6..a7f07c48b 100644 --- a/frontend-modern/src/components/shared/NodeSummaryTable.tsx +++ b/frontend-modern/src/components/shared/NodeSummaryTable.tsx @@ -161,7 +161,7 @@ export const NodeSummaryTable: Component = (props) => { return ( props.onNodeClick(nodeId, item.type)} diff --git a/frontend-modern/src/components/shared/PBSNodeTable.tsx b/frontend-modern/src/components/shared/PBSNodeTable.tsx index edead5531..7eb9b3bfa 100644 --- a/frontend-modern/src/components/shared/PBSNodeTable.tsx +++ b/frontend-modern/src/components/shared/PBSNodeTable.tsx @@ -37,38 +37,15 @@ export const PBSNodeTable: Component = (props) => { return props.searchTerm === expectedFilter; }; - // Filter and sort PBS instances + // Filter and sort PBS instances - but don't hide them when filtering, similar to PVE nodes const sortedInstances = createMemo(() => { if (!props.pbsInstances) return []; let instances = [...props.pbsInstances]; - // If we have filtered backups in backups tab, only show PBS instances with matching backups - if (props.currentTab === 'backups' && props.filteredBackups !== undefined) { - const pbsWithBackups = new Set(); - - props.filteredBackups.forEach(b => { - // PBS backups can have node as the PBS instance name or 'PBS' generic - // Check if it's a PBS backup (has datastore or node is PBS instance) - if (b.datastore || b.node === 'PBS' || b.backupType === 'remote') { - if (b.node === 'PBS' || !b.node) { - // Generic PBS backup, show all PBS instances - props.pbsInstances?.forEach(pbs => pbsWithBackups.add(pbs.name)); - } else if (props.pbsInstances?.some(pbs => pbs.name === b.node)) { - // Specific PBS instance - pbsWithBackups.add(b.node); - } - } - }); - - // If we have any PBS backups, filter to matching instances - if (pbsWithBackups.size > 0) { - instances = instances.filter(pbs => pbsWithBackups.has(pbs.name)); - } else { - // No PBS backups found in the filtered results, hide PBS table - instances = []; - } - } + // For PBS instances, always show all of them + // The selection/highlighting will indicate which one is filtered + // This keeps the PBS cards as a stable navigation element return instances.sort((a, b) => { // Healthy/online instances first diff --git a/frontend-modern/src/components/shared/PVENodeTable.tsx b/frontend-modern/src/components/shared/PVENodeTable.tsx index 8e679a90c..d1c700dda 100644 --- a/frontend-modern/src/components/shared/PVENodeTable.tsx +++ b/frontend-modern/src/components/shared/PVENodeTable.tsx @@ -41,7 +41,7 @@ export const PVENodeTable: Component = (props) => { let nodes = [...props.nodes]; - // Special handling for backups tab since it uses different filtering logic + // Special handling for backups tab - only hide nodes that have no backups at all if (props.currentTab === 'backups') { // Filter nodes to only show those with backups // First, check if nodes have any backups at all (from backupCounts) @@ -49,57 +49,11 @@ export const PVENodeTable: Component = (props) => { const backupCount = props.backupCounts?.[node.name] || 0; return backupCount > 0; }); - - // Then apply additional filtering if filteredBackups is provided - if (props.filteredBackups !== undefined) { - const nodesWithItems = new Set(); - - // Filtering is active, only show nodes with matching backups - props.filteredBackups.forEach(b => { - // Only count snapshots and local backups that actually belong to PVE nodes - if (b.backupType === 'snapshot' || b.backupType === 'local') { - // Make sure it's actually a PVE node (not a PBS instance name) - if (props.nodes.some(n => n.name === b.node)) { - nodesWithItems.add(b.node); - } - } - // PBS/remote backups have node set to PBS instance name, not PVE node - // so we don't add them to PVE node filter - }); - - // Only show nodes that have matching items - if (nodesWithItems.size > 0) { - nodes = nodes.filter(node => nodesWithItems.has(node.name)); - } else { - // If we have active filtering but no nodes have matching items, hide all nodes - nodes = []; - } - } - // If filteredBackups is undefined, still filter by backupCounts - } else if (hasActiveFilter()) { - // Handle other tabs with normal filtering logic - const nodesWithItems = new Set(); - - switch (props.currentTab) { - case 'dashboard': - // Filter based on VMs and containers - props.vms?.forEach(vm => nodesWithItems.add(vm.node)); - props.containers?.forEach(ct => nodesWithItems.add(ct.node)); - break; - case 'storage': - // Filter based on storage - props.storage?.forEach(s => nodesWithItems.add(s.node)); - break; - } - - // Only show nodes that have filtered items - if (nodesWithItems.size > 0) { - nodes = nodes.filter(node => nodesWithItems.has(node.name)); - } else if (hasActiveFilter()) { - // If we have active filtering but no nodes have matching items, hide all nodes - nodes = []; - } + // Don't further filter based on search - let highlighting handle that } + // For other tabs, ALWAYS show all nodes - don't filter them out + // The node cards should remain as a stable navigation element + // Only the data below gets filtered return nodes.sort((a, b) => { // Online nodes first @@ -167,9 +121,6 @@ export const PVENodeTable: Component = (props) => { PVE Nodes - - Status - Cluster @@ -216,36 +167,29 @@ export const PVENodeTable: Component = (props) => { const filteredGuestCount = createMemo(() => getFilteredGuestCount()); const counts = getNodeCounts(node); + // Check if this node is selected const isSelected = () => props.selectedNode === node.name; return ( props.onNodeClick(node.name)} >
- {node.name} - - - ({filteredGuestCount()} matched) - - + {node.name}
- - - {node.status} - - {node.clusterName || '-'} diff --git a/frontend-modern/src/components/shared/Tooltip.tsx b/frontend-modern/src/components/shared/Tooltip.tsx index d5db1a7bb..8d718ccff 100644 --- a/frontend-modern/src/components/shared/Tooltip.tsx +++ b/frontend-modern/src/components/shared/Tooltip.tsx @@ -36,10 +36,10 @@ const Tooltip: Component = (props) => { // Calculate position to keep tooltip on screen const rect = tooltipRef.getBoundingClientRect(); - const padding = 10; + const padding = 20; // Increased padding for better separation let x = props.x + padding; - let y = props.y - rect.height - padding; + let y = props.y - rect.height - padding - 10; // Extra 10px vertical separation // Keep within viewport if (x + rect.width > window.innerWidth) { diff --git a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx index d0c0c078b..0920233d0 100644 --- a/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx +++ b/frontend-modern/src/components/shared/UnifiedNodeSelector.tsx @@ -26,6 +26,9 @@ export const UnifiedNodeSelector: Component = (props) 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 backupCounts = createMemo(() => { const counts: Record = {}; diff --git a/frontend-modern/src/styles/animations.css b/frontend-modern/src/styles/animations.css index 4db936f06..f96140f66 100644 --- a/frontend-modern/src/styles/animations.css +++ b/frontend-modern/src/styles/animations.css @@ -190,4 +190,21 @@ opacity: 0; transform: translate(-50%, -50%) scale(1.5); } -} \ No newline at end of file +} +/* Node row click animation */ +@keyframes nodeClick { + 0% { + transform: scale(1); + } + 50% { + transform: scale(0.98); + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + } + 100% { + transform: scale(1); + } +} + +.node-click { + animation: nodeClick 0.15s ease-out; +} diff --git a/frontend-modern/vite.config.ts b/frontend-modern/vite.config.ts index daf3bab70..20d300881 100644 --- a/frontend-modern/vite.config.ts +++ b/frontend-modern/vite.config.ts @@ -14,12 +14,12 @@ export default defineConfig({ host: '0.0.0.0', // Listen on all interfaces for remote access proxy: { '/ws': { - target: 'ws://127.0.0.1:3000', + target: 'ws://127.0.0.1:7656', ws: true, changeOrigin: true, }, '/api': { - target: 'http://127.0.0.1:3000', + target: 'http://127.0.0.1:7656', changeOrigin: true, }, }, diff --git a/rebuild.sh b/rebuild.sh deleted file mode 100755 index e2e44afb4..000000000 --- a/rebuild.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash -# Rebuild script for Pulse development - -echo "Building frontend..." -cd /opt/pulse/frontend-modern && npm run build - -echo "Copying frontend to embed location..." -cp -r /opt/pulse/frontend-modern/dist /opt/pulse/internal/api/frontend-modern/ - -echo "Building Go binary..." -cd /opt/pulse && go build -o pulse ./cmd/pulse - -echo "Restarting service..." -sudo systemctl restart pulse-backend - -echo "Done!" -echo "Access Pulse at: http://localhost:7655" \ No newline at end of file