mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 19:23:31 +00:00
feat: add node card selection with content filtering
- Make PVE node cards and PBS cards selectable in Dashboard and Backups tabs - Auto-filter to 'remote' backups when PBS instance selected - Filter backups by selected PBS instance name - Disable backup type filter buttons when PBS selected - Visual feedback with blue ring on selected cards
This commit is contained in:
@@ -44,6 +44,7 @@ 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 [showFilters, setShowFilters] = createLocalStorageBooleanSignal(
|
||||
STORAGE_KEYS.BACKUPS_SHOW_FILTERS,
|
||||
false // Default to collapsed
|
||||
@@ -53,6 +54,18 @@ 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';
|
||||
@@ -114,7 +127,12 @@ const UnifiedBackups: Component = () => {
|
||||
|
||||
// Process PBS backups FIRST from the new Go backend (state.pbsBackups)
|
||||
// This ensures we have the complete PBS data with namespaces
|
||||
state.pbsBackups?.forEach((backup) => {
|
||||
// Filter by selected PBS instance if one is selected
|
||||
const pbsBackupsToProcess = selectedPBSInstance()
|
||||
? state.pbsBackups?.filter(b => b.instance === selectedPBSInstance())
|
||||
: state.pbsBackups;
|
||||
|
||||
pbsBackupsToProcess?.forEach((backup) => {
|
||||
const backupDate = new Date(backup.backupTime);
|
||||
const dateStr = backupDate.toISOString().split('T')[0];
|
||||
const timeStr = backupDate.toISOString().split('T')[1].split('.')[0].replace(/:/g, '');
|
||||
@@ -316,6 +334,12 @@ const UnifiedBackups: Component = () => {
|
||||
data = data.filter(item => item.backupType === backupType);
|
||||
}
|
||||
|
||||
// PBS instance filter - when a specific PBS is selected
|
||||
const pbsInstance = selectedPBSInstance();
|
||||
if (pbsInstance) {
|
||||
data = data.filter(item => item.node === pbsInstance);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const key = sortKey();
|
||||
const dir = sortDirection();
|
||||
@@ -655,14 +679,38 @@ const UnifiedBackups: Component = () => {
|
||||
<div class="space-y-4">
|
||||
{/* PBS Status Summary */}
|
||||
<Show when={state.pbs && state.pbs.length > 0}>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="space-y-2">
|
||||
<Show when={selectedPBSInstance()}>
|
||||
<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)}
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Show all backups
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={state.pbs}>
|
||||
{(instance) => (
|
||||
<div class="flex-1 min-w-[250px]">
|
||||
<PBSCard instance={instance} />
|
||||
<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
|
||||
);
|
||||
}}
|
||||
>
|
||||
<PBSCard
|
||||
instance={instance}
|
||||
isSelected={selectedPBSInstance() === instance.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -1154,44 +1202,52 @@ const UnifiedBackups: Component = () => {
|
||||
<div class="h-6 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
|
||||
|
||||
{/* Backup Type Filter */}
|
||||
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
|
||||
<div class={`inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5 ${selectedPBSInstance() ? 'opacity-50' : ''}`}>
|
||||
<button
|
||||
onClick={() => setBackupTypeFilter('all')}
|
||||
onClick={() => !selectedPBSInstance() && setBackupTypeFilter('all')}
|
||||
disabled={!!selectedPBSInstance()}
|
||||
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
||||
backupTypeFilter() === 'all'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} ${selectedPBSInstance() ? 'cursor-not-allowed' : 'hover:text-gray-900 dark:hover:text-gray-100'}`}
|
||||
title={selectedPBSInstance() ? 'Disabled when PBS instance is selected' : ''}
|
||||
>
|
||||
All Backups
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBackupTypeFilter('snapshot')}
|
||||
onClick={() => !selectedPBSInstance() && setBackupTypeFilter('snapshot')}
|
||||
disabled={!!selectedPBSInstance()}
|
||||
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
||||
backupTypeFilter() === 'snapshot'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} ${selectedPBSInstance() ? 'cursor-not-allowed' : 'hover:text-gray-900 dark:hover:text-gray-100'}`}
|
||||
title={selectedPBSInstance() ? 'Disabled when PBS instance is selected' : ''}
|
||||
>
|
||||
Snapshots
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBackupTypeFilter('local')}
|
||||
onClick={() => !selectedPBSInstance() && setBackupTypeFilter('local')}
|
||||
disabled={!!selectedPBSInstance()}
|
||||
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
||||
backupTypeFilter() === 'local'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} ${selectedPBSInstance() ? 'cursor-not-allowed' : 'hover:text-gray-900 dark:hover:text-gray-100'}`}
|
||||
title={selectedPBSInstance() ? 'Disabled when PBS instance is selected' : ''}
|
||||
>
|
||||
PVE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBackupTypeFilter('remote')}
|
||||
onClick={() => !selectedPBSInstance() && setBackupTypeFilter('remote')}
|
||||
disabled={!!selectedPBSInstance()}
|
||||
class={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${
|
||||
backupTypeFilter() === 'remote'
|
||||
? 'bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100'
|
||||
}`}
|
||||
: 'text-gray-600 dark:text-gray-400'
|
||||
} ${selectedPBSInstance() ? 'cursor-not-allowed' : 'hover:text-gray-900 dark:hover:text-gray-100'}`}
|
||||
title={selectedPBSInstance() ? 'PBS filter locked when instance selected' : ''}
|
||||
>
|
||||
PBS
|
||||
</button>
|
||||
|
||||
@@ -24,6 +24,7 @@ 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);
|
||||
|
||||
// Initialize from localStorage with proper type checking
|
||||
const storedViewMode = localStorage.getItem('dashboardViewMode');
|
||||
@@ -174,6 +175,11 @@ 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');
|
||||
@@ -318,16 +324,39 @@ export function Dashboard(props: DashboardProps) {
|
||||
return (
|
||||
<div>
|
||||
{/* Node Summary Cards - Adaptive Layout */}
|
||||
<div id="node-summary-cards-container" class="mb-3">
|
||||
<div id="node-summary-cards-container" class="mb-3 space-y-2">
|
||||
<Show when={selectedNode()}>
|
||||
<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)}
|
||||
class="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
Clear filter
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
<div>
|
||||
<Show when={props.nodes.length > 0}>
|
||||
{/* Regular cards for 1-4 nodes */}
|
||||
<Show when={props.nodes.length <= 4}>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<For each={props.nodes}>
|
||||
{(node) => (
|
||||
<div class="flex-1 min-w-[250px]">
|
||||
<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
|
||||
);
|
||||
}}
|
||||
>
|
||||
<ComponentErrorBoundary name="NodeCard">
|
||||
<NodeCard node={node} />
|
||||
<NodeCard
|
||||
node={node}
|
||||
isSelected={selectedNode() === node.name}
|
||||
/>
|
||||
</ComponentErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
@@ -361,6 +390,7 @@ export function Dashboard(props: DashboardProps) {
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Filter */}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWebSocket } from '@/App';
|
||||
|
||||
interface NodeCardProps {
|
||||
node: Node;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
const NodeCard: Component<NodeCardProps> = (props) => {
|
||||
@@ -120,6 +121,10 @@ const NodeCard: Component<NodeCardProps> = (props) => {
|
||||
|
||||
// Determine border/ring style based on status and alerts
|
||||
const getBorderClass = () => {
|
||||
// Selected nodes get blue ring
|
||||
if (props.isSelected) {
|
||||
return 'ring-2 ring-blue-500 border border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
// Offline nodes get red ring
|
||||
if (!isOnline()) {
|
||||
return 'ring-2 ring-red-500 border border-gray-200 dark:border-gray-700';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatUptime, formatBytes } from '@/utils/format';
|
||||
|
||||
interface PBSCardProps {
|
||||
instance: PBSInstance;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
|
||||
const PBSCard: Component<PBSCardProps> = (props) => {
|
||||
@@ -111,6 +112,10 @@ const PBSCard: Component<PBSCardProps> = (props) => {
|
||||
};
|
||||
|
||||
const getBorderClass = () => {
|
||||
// Selected PBS gets blue ring
|
||||
if (props.isSelected) {
|
||||
return 'ring-2 ring-blue-500 border border-gray-200 dark:border-gray-700';
|
||||
}
|
||||
// Offline PBS gets red ring
|
||||
if (!isOnline()) {
|
||||
return 'ring-2 ring-red-500 border border-gray-200 dark:border-gray-700';
|
||||
|
||||
Reference in New Issue
Block a user