feat: add grouping toggle for flat list view

adds a grouped/list toggle to the dashboard that allows users to switch between:
- grouped view: vms/containers grouped by node (default)
- list view: flat list of all vms/containers across cluster

in list view, users can sort by any column to identify top resource consumers across the entire cluster, useful for monitoring high bandwidth/cpu/memory usage

addresses #370
This commit is contained in:
Pulse Monitor
2025-08-28 14:39:36 +00:00
parent 027db449aa
commit ca28f42edb
2 changed files with 102 additions and 4 deletions
@@ -20,6 +20,7 @@ interface DashboardProps {
type ViewMode = 'all' | 'vm' | 'lxc';
type StatusMode = 'all' | 'running' | 'stopped';
type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
@@ -56,6 +57,12 @@ export function Dashboard(props: DashboardProps) {
(storedStatusMode === 'all' || storedStatusMode === 'running' || storedStatusMode === 'stopped') ? storedStatusMode : 'all'
);
// Grouping mode - grouped by node or flat list
const storedGroupingMode = localStorage.getItem('dashboardGroupingMode');
const [groupingMode, setGroupingMode] = createSignal<GroupingMode>(
(storedGroupingMode === 'grouped' || storedGroupingMode === 'flat') ? storedGroupingMode : 'grouped'
);
const [showFilters, setShowFilters] = createSignal(
localStorage.getItem('dashboardShowFilters') !== null
? localStorage.getItem('dashboardShowFilters') === 'true'
@@ -91,6 +98,10 @@ export function Dashboard(props: DashboardProps) {
localStorage.setItem('dashboardStatusMode', statusMode());
});
createEffect(() => {
localStorage.setItem('dashboardGroupingMode', groupingMode());
});
createEffect(() => {
@@ -241,10 +252,64 @@ export function Dashboard(props: DashboardProps) {
return guests;
});
// Group by node
// Group by node or return flat list based on grouping mode
const groupedGuests = createMemo(() => {
const guests = filteredGuests();
// If flat mode, return all guests in a single group
if (groupingMode() === 'flat') {
const groups: Record<string, (VM | Container)[]> = { '': guests };
// Sort the flat list
const key = sortKey();
const dir = sortDirection();
if (key) {
groups[''] = groups[''].sort((a, b) => {
let aVal: string | number | boolean | null | undefined = a[key] as string | number | boolean | null | undefined;
let bVal: string | number | boolean | null | undefined = b[key] as string | number | boolean | null | undefined;
// Special handling for percentage-based columns
if (key === 'cpu') {
// CPU is displayed as percentage
aVal = a.cpu * 100;
bVal = b.cpu * 100;
} else if (key === 'memory') {
// Memory is displayed as percentage (use pre-calculated usage)
aVal = a.memory ? (a.memory.usage || 0) : 0;
bVal = b.memory ? (b.memory.usage || 0) : 0;
} else if (key === 'disk') {
// Disk is displayed as percentage
aVal = a.disk.total > 0 ? (a.disk.used / a.disk.total) * 100 : 0;
bVal = b.disk.total > 0 ? (b.disk.used / b.disk.total) * 100 : 0;
}
// Handle null/undefined/empty values - put at end for both asc and desc
const aIsEmpty = aVal === null || aVal === undefined || aVal === '';
const bIsEmpty = bVal === null || bVal === undefined || bVal === '';
if (aIsEmpty && bIsEmpty) return 0;
if (aIsEmpty) return 1;
if (bIsEmpty) return -1;
// Type-specific value preparation
if (typeof aVal === 'number' && typeof bVal === 'number') {
// Numeric comparison
const comparison = aVal < bVal ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
} else {
// String comparison (case-insensitive)
const aStr = String(aVal).toLowerCase();
const bStr = String(bVal).toLowerCase();
if (aStr === bStr) return 0;
const comparison = aStr < bStr ? -1 : 1;
return dir === 'asc' ? comparison : -comparison;
}
});
}
return groups;
}
// Original grouped by node logic
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach(guest => {
if (!groups[guest.node]) {
@@ -489,6 +554,8 @@ export function Dashboard(props: DashboardProps) {
setViewMode={setViewMode}
statusMode={statusMode}
setStatusMode={setStatusMode}
groupingMode={groupingMode}
setGroupingMode={setGroupingMode}
setSortKey={setSortKey}
setSortDirection={setSortDirection}
searchInputRef={(el) => searchInputRef = el}
@@ -631,7 +698,7 @@ export function Dashboard(props: DashboardProps) {
<For each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))} fallback={<></>}>
{([node, guests]) => (
<>
<Show when={node}>
<Show when={node && groupingMode() === 'grouped'}>
<tr class="bg-gray-50/50 dark:bg-gray-700/30">
<td class="p-1 px-2 text-xs font-medium text-gray-600 dark:text-gray-400 w-[200px]">
<a
@@ -655,7 +722,7 @@ export function Dashboard(props: DashboardProps) {
return (
<GuestRow
guest={guest}
showNode={false}
showNode={groupingMode() === 'flat'}
alertStyles={getAlertStyles(guestId, activeAlerts)}
/>
);
@@ -9,6 +9,8 @@ interface DashboardFilterProps {
setViewMode: (value: 'all' | 'vm' | 'lxc') => void;
statusMode: () => 'all' | 'running' | 'stopped';
setStatusMode: (value: 'all' | 'running' | 'stopped') => void;
groupingMode: () => 'grouped' | 'flat';
setGroupingMode: (value: 'grouped' | 'flat') => void;
setSortKey: (value: string) => void;
setSortDirection: (value: string) => void;
searchInputRef?: (el: HTMLInputElement) => void;
@@ -146,6 +148,34 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Grouping Mode Toggle */}
<div class="inline-flex rounded-lg bg-gray-100 dark:bg-gray-700 p-0.5">
<button type="button"
onClick={() => props.setGroupingMode('grouped')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'grouped'
? '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'
}`}
title="Group by node"
>
Grouped
</button>
<button type="button"
onClick={() => props.setGroupingMode('flat')}
class={`px-2.5 py-1 text-xs font-medium rounded-md transition-all ${
props.groupingMode() === 'flat'
? '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'
}`}
title="Flat list view"
>
List
</button>
</div>
<div class="h-5 w-px bg-gray-200 dark:bg-gray-600 hidden sm:block"></div>
{/* Reset Button */}
<button
onClick={() => {
@@ -154,6 +184,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
props.setSortDirection('asc');
props.setViewMode('all');
props.setStatusMode('all');
props.setGroupingMode('grouped');
}}
title="Reset all filters"
class="flex items-center justify-center px-2.5 py-1 text-xs font-medium text-gray-600 dark:text-gray-400
@@ -170,7 +201,7 @@ export const DashboardFilter: Component<DashboardFilterProps> = (props) => {
</button>
{/* Active Indicator */}
<Show when={props.search() || props.viewMode() !== 'all' || props.statusMode() !== 'all'}>
<Show when={props.search() || props.viewMode() !== 'all' || props.statusMode() !== 'all' || props.groupingMode() !== 'grouped'}>
<span class="text-xs bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 px-2 py-0.5 rounded-full font-medium">
Active
</span>