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
This commit is contained in:
Pulse Monitor
2025-08-29 10:39:45 +00:00
parent 6d93fcd391
commit 597e695f0e
17 changed files with 79 additions and 154 deletions
@@ -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'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
@@ -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'
}`}
>
<div class="font-medium text-sm text-gray-800 dark:text-gray-200">
@@ -956,7 +956,7 @@ const UnifiedBackups: Component = () => {
return (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${
isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
onClick={() => {
@@ -1682,7 +1682,7 @@ const UnifiedBackups: Component = () => {
</tr>
<For each={group.items}>
{(item) => (
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700">
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="p-0.5 px-1.5 text-sm align-middle">
{item.name || '-'}
</td>
@@ -1726,14 +1726,14 @@ const UnifiedBackups: Component = () => {
{item.backupType === 'snapshot' ? 'Snapshot' : item.backupType === 'local' ? 'PVE' : 'PBS'}
</span>
<Show when={item.encrypted}>
<span title="Encrypted backup" class="text-green-600 dark:text-green-400">
<span title="Encrypted backup" class="text-green-600 dark:text-green-400 inline-block ml-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
</span>
</Show>
<Show when={item.protected}>
<span title="Protected backup" class="text-blue-600 dark:text-blue-400">
<span title="Protected backup" class="text-blue-600 dark:text-blue-400 inline-block ml-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
@@ -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"
@@ -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<string | null>(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 (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${
isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
onClick={() => {
@@ -817,7 +823,7 @@ export function Dashboard(props: DashboardProps) {
</span>
<span class="text-gray-400">|</span>
<span class="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
<span class="h-2 w-2 bg-gray-400 rounded-full"></span>
<span class="h-2 w-2 bg-red-500 rounded-full"></span>
{totalStats().stopped} stopped
</span>
</div>
@@ -27,16 +27,10 @@ export const DashboardFilter: Component<DashboardFilterProps> = (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"
/>
<svg class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -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) {
<div class="flex items-center gap-2">
{/* Status indicator */}
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${
isRunning() ? 'bg-green-500' : 'bg-gray-400'
isRunning() ? 'bg-green-500' : 'bg-red-500'
}`} title={props.guest.status}></span>
{/* Name - clickable if custom URL is set */}
@@ -93,7 +93,7 @@ export const TagBadges: Component<TagBadgesProps> = (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<TagBadgesProps> = (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,
@@ -325,7 +325,7 @@ export const DiscoveryModal: Component<DiscoveryModalProps> = (props) => {
<For each={discoveryResult()!.servers}>
{(server) => (
<div
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 cursor-pointer transition-all hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/50 group"
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 cursor-pointer transition-all hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 hover:bg-gray-50 dark:hover:bg-gray-700/30 group"
onClick={() => handleAddServer(server)}
>
<div class="flex items-start justify-between">
@@ -328,7 +328,7 @@ const Storage: Component = () => {
: 'p-0.5 px-1.5';
return (
<tr class={`${rowClass} hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors`}>
<tr class={`${rowClass} hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors`}>
<td class={firstCellClass}>
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
@@ -161,7 +161,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return (
<tr
class={`hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors ${
class={`hover:bg-gray-50 dark:hover:bg-gray-700/30 cursor-pointer transition-colors ${
isSelected() ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
onClick={() => props.onNodeClick(nodeId, item.type)}
@@ -37,38 +37,15 @@ export const PBSNodeTable: Component<PBSNodeTableProps> = (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<string>();
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
@@ -41,7 +41,7 @@ export const PVENodeTable: Component<PVENodeTableProps> = (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<PVENodeTableProps> = (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<string>();
// 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<string>();
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<PVENodeTableProps> = (props) => {
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap" style="min-width: 200px;">
PVE Nodes
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap" style="min-width: 80px;">
Status
</th>
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider whitespace-nowrap" style="min-width: 100px;">
Cluster
</th>
@@ -216,36 +167,29 @@ export const PVENodeTable: Component<PVENodeTableProps> = (props) => {
const filteredGuestCount = createMemo(() => getFilteredGuestCount());
const counts = getNodeCounts(node);
// Check if this node is selected
const isSelected = () => props.selectedNode === node.name;
return (
<tr
class={`
border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30
transition-all duration-150 ease-in-out cursor-pointer h-8
hover:scale-[1.01] hover:shadow-md hover:z-10 relative
hover:border-l-4 hover:border-l-blue-500 dark:hover:border-l-blue-400
border-b border-gray-100 dark:border-gray-700/50
transition-all duration-200 ease-out cursor-pointer h-8
hover:bg-gray-50 dark:hover:bg-gray-700/30
${!isOnline() ? 'opacity-60' : ''}
${isSelected() ? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 scale-[1.005] shadow-sm border-l-4 border-l-blue-600 dark:border-l-blue-500' : ''}
${isSelected()
? 'bg-blue-50 dark:bg-blue-900/20 border-l-4 border-l-blue-500 dark:border-l-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30'
: ''
}
`}
onClick={() => props.onNodeClick(node.name)}
>
<td class="p-1 px-2 whitespace-nowrap">
<div class="flex items-center gap-2">
<span class={`h-2 w-2 rounded-full flex-shrink-0 ${isOnline() ? 'bg-green-500' : 'bg-red-500'}`}></span>
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 truncate" title={node.name}>{node.name}</span>
<Show when={filteredGuestCount() !== null && props.searchTerm && props.searchTerm.trim()}>
<span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
({filteredGuestCount()} matched)
</span>
</Show>
<span class={`text-sm truncate ${isSelected() ? 'font-semibold text-blue-700 dark:text-blue-300' : 'font-medium text-gray-900 dark:text-gray-100'}`} title={node.name}>{node.name}</span>
</div>
</td>
<td class="p-1 px-2">
<span class={`text-xs whitespace-nowrap ${isOnline() ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
{node.status}
</span>
</td>
<td class="p-1 px-2">
<span class="text-xs text-gray-600 dark:text-gray-400 block truncate" title={node.clusterName || ''}>
{node.clusterName || '-'}
@@ -36,10 +36,10 @@ const Tooltip: Component<TooltipProps> = (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) {
@@ -26,6 +26,9 @@ export const UnifiedNodeSelector: Component<UnifiedNodeSelectorProps> = (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<string, number> = {};
+18 -1
View File
@@ -190,4 +190,21 @@
opacity: 0;
transform: translate(-50%, -50%) scale(1.5);
}
}
}
/* 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;
}
+2 -2
View File
@@ -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,
},
},
-17
View File
@@ -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"