mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-22 03:04:03 +00:00
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user