feat: enhance disk monitoring UI with dashboard widget and node badges (addresses #429)

- Added DiskHealthSummary widget to dashboard showing:
  - Total disk health status overview
  - Healthy/failing/low-life disk counts
  - Average SSD life remaining with visual bar
  - Distribution of disks across nodes
- Added disk count badges to node selector in storage tab
- Shows disk counts next to storage pools count per node
- Webhook notifications automatically trigger for disk alerts via existing system
- Dashboard widget highlights issues with color-coded status indicators
This commit is contained in:
Pulse Monitor
2025-09-08 16:59:01 +00:00
parent cb08dd85d4
commit f5334fc413
3 changed files with 142 additions and 6 deletions
@@ -11,6 +11,7 @@ import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector';
import { MetricBar } from './MetricBar';
import { formatBytes, formatUptime } from '@/utils/format';
import { DashboardFilter } from './DashboardFilter';
import { DiskHealthSummary } from './DiskHealthSummary';
interface DashboardProps {
vms: VM[];
@@ -24,7 +25,7 @@ type GroupingMode = 'grouped' | 'flat';
export function Dashboard(props: DashboardProps) {
const { connected, activeAlerts, initialDataReceived } = useWebSocket();
const { connected, activeAlerts, initialDataReceived, state } = useWebSocket();
const [search, setSearch] = createSignal('');
const [isSearchLocked, setIsSearchLocked] = createSignal(false);
const [selectedNode, setSelectedNode] = createSignal<string | null>(null);
@@ -475,6 +476,11 @@ export function Dashboard(props: DashboardProps) {
searchTerm={search()}
/>
{/* Disk Health Summary Widget */}
<div class="mb-4">
<DiskHealthSummary disks={state.physicalDisks || []} />
</div>
{/* Removed old node table - keeping the rest unchanged */}
<Show when={false}>
<div class="mb-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700">
@@ -0,0 +1,129 @@
import { Component, createMemo, Show } from 'solid-js';
import type { PhysicalDisk } from '@/types/api';
interface DiskHealthSummaryProps {
disks: PhysicalDisk[];
}
export const DiskHealthSummary: Component<DiskHealthSummaryProps> = (props) => {
const diskStats = createMemo(() => {
const disks = props.disks || [];
const total = disks.length;
const healthy = disks.filter(d => d.health === 'PASSED').length;
const failing = disks.filter(d => d.health === 'FAILED').length;
const unknown = disks.filter(d => d.health === 'UNKNOWN' || !d.health).length;
const lowLife = disks.filter(d => d.wearout > 0 && d.wearout < 10).length;
const avgWearout = disks.filter(d => d.wearout > 0).reduce((sum, d) => sum + d.wearout, 0) /
disks.filter(d => d.wearout > 0).length || 0;
// Group by node
const byNode: Record<string, number> = {};
disks.forEach(d => {
byNode[d.node] = (byNode[d.node] || 0) + 1;
});
return {
total,
healthy,
failing,
unknown,
lowLife,
avgWearout,
byNode
};
});
const healthColor = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'text-red-600 dark:text-red-400';
if (stats.lowLife > 0) return 'text-yellow-600 dark:text-yellow-400';
if (stats.unknown > 0) return 'text-gray-600 dark:text-gray-400';
return 'text-green-600 dark:text-green-400';
});
const healthBg = createMemo(() => {
const stats = diskStats();
if (stats.failing > 0) return 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800';
if (stats.lowLife > 0) return 'bg-yellow-50 dark:bg-yellow-950/20 border-yellow-200 dark:border-yellow-800';
return 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700';
});
return (
<Show when={diskStats().total > 0}>
<div class={`rounded-lg p-4 border ${healthBg()}`}>
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">
Disk Health Summary
</h3>
<span class={`text-2xl font-bold ${healthColor()}`}>
{diskStats().healthy}/{diskStats().total}
</span>
</div>
<div class="space-y-2">
{/* Health Status */}
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">Status</span>
<div class="flex gap-3">
<Show when={diskStats().healthy > 0}>
<span class="text-green-600 dark:text-green-400">
{diskStats().healthy}
</span>
</Show>
<Show when={diskStats().failing > 0}>
<span class="text-red-600 dark:text-red-400">
{diskStats().failing}
</span>
</Show>
<Show when={diskStats().lowLife > 0}>
<span class="text-yellow-600 dark:text-yellow-400">
{diskStats().lowLife} low
</span>
</Show>
<Show when={diskStats().unknown > 0}>
<span class="text-gray-500">
{diskStats().unknown}
</span>
</Show>
</div>
</div>
{/* Average SSD Life */}
<Show when={diskStats().avgWearout > 0}>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-600 dark:text-gray-400">Avg SSD Life</span>
<div class="flex items-center gap-2">
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
<div
class={`h-1.5 rounded-full transition-all ${
diskStats().avgWearout >= 50 ? 'bg-green-500' :
diskStats().avgWearout >= 20 ? 'bg-yellow-500' :
diskStats().avgWearout >= 10 ? 'bg-orange-500' :
'bg-red-500'
}`}
style={`width: ${diskStats().avgWearout}%`}
/>
</div>
<span class="text-gray-700 dark:text-gray-300 font-medium">
{Math.round(diskStats().avgWearout)}%
</span>
</div>
</div>
</Show>
{/* Disk Distribution */}
<div class="pt-2 mt-2 border-t border-gray-200 dark:border-gray-700">
<div class="text-xs text-gray-600 dark:text-gray-400 mb-1">Distribution</div>
<div class="flex flex-wrap gap-1">
{Object.entries(diskStats().byNode).map(([node, count]) => (
<span class="px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs">
{node}: {count}
</span>
))}
</div>
</div>
</div>
</div>
</Show>
);
};
@@ -18,7 +18,7 @@ interface NodeSummaryTableProps {
}
export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const { activeAlerts } = useWebSocket();
const { activeAlerts, state } = useWebSocket();
// Combine and sort nodes based on tab
const sortedItems = createMemo(() => {
const items: Array<{ type: 'pve' | 'pbs'; data: Node | PBSInstance }> = [];
@@ -56,7 +56,7 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
const getCountHeader = () => {
switch (props.currentTab) {
case 'dashboard': return ['VMs', 'Containers'];
case 'storage': return ['Storage'];
case 'storage': return ['Storage', 'Disks'];
case 'backups': return ['Backups'];
default: return [];
}
@@ -71,8 +71,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
// PBS doesn't have VMs/Containers, return dashes
return ['-', '-'];
case 'storage':
// PBS doesn't have storage count
return ['-'];
// PBS doesn't have storage or disk count
return ['-', '-'];
case 'backups':
// PBS shows backup count
return [props.backupCounts?.[item.data.name] || 0];
@@ -89,7 +89,8 @@ export const NodeSummaryTable: Component<NodeSummaryTableProps> = (props) => {
return [vmCount, containerCount];
case 'storage':
const storageCount = props.storage?.filter(s => s.node === node.name).length || 0;
return [storageCount];
const diskCount = state.physicalDisks?.filter(d => d.node === node.name).length || 0;
return [storageCount, diskCount];
case 'backups':
return [props.backupCounts?.[node.name] || 0];
default: