import { Component, For, Show, createSignal, createMemo, createEffect } from 'solid-js'; import { useNavigate } from '@solidjs/router'; import { useWebSocket } from '@/App'; import { getAlertStyles } from '@/utils/alerts'; import { formatBytes, formatPercent } from '@/utils/format'; import type { Storage as StorageType, CephCluster } from '@/types/api'; import { ComponentErrorBoundary } from '@/components/ErrorBoundary'; import { UnifiedNodeSelector } from '@/components/shared/UnifiedNodeSelector'; import { StorageFilter } from './StorageFilter'; import { DiskList } from './DiskList'; import { ZFSHealthMap } from './ZFSHealthMap'; import { EnhancedStorageBar } from './EnhancedStorageBar'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { NodeGroupHeader } from '@/components/shared/NodeGroupHeader'; import { ProxmoxSectionNav } from '@/components/Proxmox/ProxmoxSectionNav'; import { getNodeDisplayName } from '@/utils/nodes'; import { usePersistentSignal } from '@/hooks/usePersistentSignal'; import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useAlertsActivation } from '@/stores/alertsActivation'; type StorageSortKey = 'name' | 'node' | 'type' | 'status' | 'usage' | 'free' | 'total'; const Storage: Component = () => { const navigate = useNavigate(); const { state, connected, activeAlerts, initialDataReceived, reconnecting, reconnect } = useWebSocket(); const alertsActivation = useAlertsActivation(); const alertsEnabled = createMemo(() => alertsActivation.activationState() === 'active'); const [viewMode, setViewMode] = usePersistentSignal<'node' | 'storage'>( 'storageViewMode', 'node', { deserialize: (raw) => (raw === 'storage' ? 'storage' : 'node'), }, ); const [tabView, setTabView] = createSignal<'pools' | 'disks'>('pools'); const [searchTerm, setSearchTerm] = createSignal(''); const [selectedNode, setSelectedNode] = createSignal(null); const [expandedStorage, setExpandedStorage] = createSignal(null); const [sortKey, setSortKey] = usePersistentSignal('storageSortKey', 'name', { deserialize: (raw) => (['name', 'node', 'type', 'status', 'usage', 'free', 'total'] as const).includes( raw as StorageSortKey, ) ? (raw as StorageSortKey) : 'name', }); const [sortDirection, setSortDirection] = usePersistentSignal<'asc' | 'desc'>( 'storageSortDirection', 'asc', { deserialize: (raw) => (raw === 'desc' ? 'desc' : 'asc'), }, ); const [statusFilter, setStatusFilter] = usePersistentSignal<'all' | 'available' | 'offline'>( 'storageStatusFilter', 'all', { deserialize: (raw) => raw === 'all' || raw === 'available' || raw === 'offline' ? raw : 'all', }, ); // PERFORMANCE: Debounce search term to prevent jank during rapid typing const debouncedSearchTerm = useDebouncedValue(() => searchTerm(), 200); // Create a mapping from node instance ID to node object const nodeByInstance = createMemo(() => { const map: Record = {}; (state.nodes || []).forEach((node) => { map[node.id] = node; }); return map; }); const isCephType = (type?: string) => { const value = (type || '').toLowerCase(); return value === 'rbd' || value === 'cephfs' || value === 'ceph'; }; const getCephHealthLabel = (health?: string) => { if (!health) return 'CEPH'; const normalized = health.toUpperCase(); return normalized.startsWith('HEALTH_') ? normalized.replace('HEALTH_', '') : normalized; }; const getCephHealthStyles = (health?: string) => { const normalized = (health || '').toUpperCase(); if (normalized === 'HEALTH_OK') { return 'bg-green-100 text-green-700 dark:bg-green-900/60 dark:text-green-300 border border-green-200 dark:border-green-800'; } if (normalized === 'HEALTH_WARN' || normalized === 'HEALTH_WARNING') { return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/60 dark:text-yellow-200 border border-yellow-300 dark:border-yellow-800'; } if (normalized === 'HEALTH_ERR' || normalized === 'HEALTH_ERROR' || normalized === 'HEALTH_CRIT') { return 'bg-red-100 text-red-700 dark:bg-red-900/60 dark:text-red-200 border border-red-300 dark:border-red-800'; } return 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200 border border-blue-200 dark:border-blue-700'; }; const cephClusters = createMemo(() => state.cephClusters || []); const visibleCephClusters = createMemo(() => { const explicit = cephClusters(); if (explicit && explicit.length > 0) { return explicit; } const storageList = state.storage || []; const summaryByInstance = new Map< string, { total: number; used: number; nodes: Set; storages: number; } >(); storageList.forEach((item) => { if (!isCephType(item?.type)) { return; } const instance = item.instance || 'ceph'; let summary = summaryByInstance.get(instance); if (!summary) { summary = { total: 0, used: 0, nodes: new Set(), storages: 0, }; summaryByInstance.set(instance, summary); } summary.total += Math.max(0, item.total || 0); summary.used += Math.max(0, item.used || 0); summary.storages += 1; if (Array.isArray(item.nodes)) { item.nodes.forEach((node) => node && summary!.nodes.add(node)); } else if (item.node) { summary.nodes.add(item.node); } }); return Array.from(summaryByInstance.entries()).map(([instance, info], index) => { const totalBytes = info.total; const usedBytes = info.used; const availableBytes = Math.max(totalBytes - usedBytes, 0); const usagePercent = totalBytes > 0 ? (usedBytes / totalBytes) * 100 : 0; const numOsds = Math.max(1, info.storages * 2); const numMons = Math.min(3, Math.max(1, info.nodes.size)); return { id: `${instance}-derived-${index}`, instance, name: `${instance} Ceph`, health: 'HEALTH_UNKNOWN', healthMessage: 'Derived from storage metrics – live Ceph telemetry unavailable.', totalBytes, usedBytes, availableBytes, usagePercent, numMons, numMgrs: numMons > 1 ? 2 : 1, numOsds, numOsdsUp: numOsds, numOsdsIn: numOsds, numPGs: Math.max(128, info.storages * 128), pools: undefined, services: undefined, lastUpdated: Date.now(), } as CephCluster; }); }); const cephClusterByInstance = createMemo>(() => { const map: Record = {}; visibleCephClusters().forEach((cluster) => { if (cluster?.instance) { map[cluster.instance] = cluster; } }); return map; }); const sortKeyOptions: { value: StorageSortKey; label: string }[] = [ { value: 'name', label: 'Name' }, { value: 'node', label: 'Node' }, { value: 'type', label: 'Type' }, { value: 'status', label: 'Status' }, { value: 'usage', label: 'Usage %' }, { value: 'free', label: 'Free Capacity' }, { value: 'total', label: 'Total Capacity' }, ]; // Filter storage - in storage view, filter out 0 capacity and deduplicate const filteredStorage = createMemo(() => { let storage = state.storage || []; // In storage view, deduplicate identical storage and filter out 0 capacity if (viewMode() === 'storage') { // Filter out 0 capacity first storage = storage.filter((s) => s.total > 0); // Deduplicate storage entries that are identical const storageMap = new Map(); storage.forEach((s) => { let key; const nodeId = `${s.instance}-${s.node}`; // For PBS storage, group by capacity since they're the same PBS server // PBS namespaces (pbs-node1, pbs-node2) pointing to same server should be grouped if (s.type === 'pbs' && s.shared) { // Group shared PBS by type, total size, and usage (same PBS server = same capacity) key = `pbs-${s.total}-${s.used}`; } else if (s.shared) { // Other shared storage should only appear once key = `${s.name}-${s.type}`; } else { // Non-shared storage (local, local-zfs) - NEVER deduplicate these! // Each node has its own physical storage key = `${s.node}-${s.name}-${s.type}`; } if (!storageMap.has(key)) { const backendNodes = (s.nodes ?? []).filter((node): node is string => Boolean(node)); const backendNodeIds = ((s as { nodeIds?: string[] }).nodeIds ?? []).filter( (id): id is string => Boolean(id), ); const rawNodes = backendNodes.length > 0 ? backendNodes : [s.node].filter(Boolean); const uniqueNodes = Array.from(new Set(rawNodes)); const normalizedInitialNodes = uniqueNodes.length > 1 ? uniqueNodes.filter((node) => node !== 'cluster') : uniqueNodes; const nodesForStorage = normalizedInitialNodes.length > 0 ? normalizedInitialNodes : uniqueNodes; const initialNodeIds = Array.from( new Set(backendNodeIds.length > 0 ? backendNodeIds : [nodeId].filter(Boolean)), ); const initialPBSNames = s.type === 'pbs' ? Array.from(new Set((s.pbsNames ?? [s.name]).filter((name): name is string => Boolean(name)))) : undefined; // First occurrence - store it with node list storageMap.set(key, { ...s, name: s.type === 'pbs' ? 'PBS Storage' : s.name, // Generic name for PBS nodes: nodesForStorage, nodeIds: initialNodeIds, nodeCount: nodesForStorage.length, pbsNames: initialPBSNames, // Track individual PBS names }); } else { // Duplicate - just add to node list const existing = storageMap.get(key); const backendNodes = (s.nodes ?? []).filter((node): node is string => Boolean(node)); const rawCandidateNodes = backendNodes.length > 0 ? backendNodes : [s.node].filter(Boolean); const candidateNodes = rawCandidateNodes.length > 1 ? rawCandidateNodes.filter((node) => node !== 'cluster') : rawCandidateNodes; candidateNodes.forEach((node) => { if (!existing.nodes.includes(node)) { existing.nodes.push(node); } }); existing.nodeCount = existing.nodes.length; const backendNodeIds = ((s as { nodeIds?: string[] }).nodeIds ?? []).filter( (id): id is string => Boolean(id), ); const candidateNodeIds = backendNodeIds.length > 0 ? backendNodeIds : [nodeId].filter(Boolean); if (!existing.nodeIds) { existing.nodeIds = []; } candidateNodeIds.forEach((id) => { if (!existing.nodeIds!.includes(id)) { existing.nodeIds!.push(id); } }); // For PBS, collect all namespace names (merging backend-provided data) if (s.type === 'pbs') { if (!existing.pbsNames) { existing.pbsNames = []; } const incomingPBSNames = (s.pbsNames ?? [s.name]).filter( (name): name is string => Boolean(name), ); incomingPBSNames.forEach((name) => { if (!existing.pbsNames!.includes(name)) { existing.pbsNames!.push(name); } }); } } }); // Convert back to array storage = Array.from(storageMap.values()); } return storage; }); // Sort and filter storage const sortedStorage = createMemo(() => { let storage = [...filteredStorage()]; // Apply node selection filter with instance-aware matching const nodeFilter = selectedNode(); if (nodeFilter) { const node = state.nodes?.find((n) => n.id === nodeFilter); if (node) { const nodeId = `${node.instance}-${node.name}`; storage = storage.filter((s) => { const belongsToNode = s.instance === node.instance && s.node === node.name; const aggregatedNodeIds = (s as { nodeIds?: string[] }).nodeIds ?? []; return belongsToNode || aggregatedNodeIds.includes(nodeId); }); } } // Apply status filter if (statusFilter() !== 'all') { storage = storage.filter((s) => { if (statusFilter() === 'available') { return s.status === 'available'; } else { // Offline includes anything not available return s.status !== 'available'; } }); } // Apply search filter - PERFORMANCE: Use debounced search term let search = debouncedSearchTerm().toLowerCase().trim(); if (search) { const nodePattern = /node:([a-z0-9_.:-]+)/i; const nodeMatch = search.match(nodePattern); let nodeQuery: string | null = null; if (nodeMatch) { nodeQuery = nodeMatch[1].toLowerCase(); search = search.replace(nodeMatch[0], '').trim(); } if (nodeQuery) { storage = storage.filter((s) => { const primary = s.node?.toLowerCase(); const extraNodes = s.nodes?.map((node) => node.toLowerCase()) || []; return primary === nodeQuery || extraNodes.includes(nodeQuery); }); } if (search) { const terms = search.split(/\s+/).filter(Boolean); storage = storage.filter((s) => { const haystack = [ s.name, s.node, s.type, s.content, s.status, ...(s.nodes ?? []), ...(s.pbsNames ?? []), ] .filter(Boolean) .map((value) => value!.toLowerCase()); return terms.every((term) => haystack.some((entry) => entry.includes(term))); }); } } const numericCompare = (a: number, b: number) => { const normalizedA = Number.isFinite(a) ? a : -Infinity; const normalizedB = Number.isFinite(b) ? b : -Infinity; if (normalizedA === normalizedB) return 0; return normalizedA < normalizedB ? -1 : 1; }; const result = storage.sort((a, b) => { let comparison = 0; switch (sortKey()) { case 'node': { const nodeA = (a.nodes && a.nodes.length > 0 ? a.nodes[0] : a.node) ?? ''; const nodeB = (b.nodes && b.nodes.length > 0 ? b.nodes[0] : b.node) ?? ''; comparison = nodeA.localeCompare(nodeB, undefined, { sensitivity: 'base' }); break; } case 'type': comparison = (a.type ?? '').localeCompare(b.type ?? '', undefined, { sensitivity: 'base', }); break; case 'status': comparison = (a.status ?? '').localeCompare(b.status ?? '', undefined, { sensitivity: 'base', }); break; case 'usage': comparison = numericCompare(a.usage ?? 0, b.usage ?? 0); break; case 'free': comparison = numericCompare(a.free ?? 0, b.free ?? 0); break; case 'total': comparison = numericCompare(a.total ?? 0, b.total ?? 0); break; case 'name': default: comparison = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base', }); break; } if (comparison === 0) { comparison = (a.name ?? '').localeCompare(b.name ?? '', undefined, { sensitivity: 'base' }); } return sortDirection() === 'asc' ? comparison : -comparison; }); return result; }); // Group storage by node or storage const groupedStorage = createMemo(() => { const storage = sortedStorage(); const mode = viewMode(); if (mode === 'node') { // Group by node ID (instance + node name) to match Node.ID format const groups: Record = {}; storage.forEach((s) => { if (s.shared) { const nodeCandidates = [ ...((s.nodeIds ?? []) .map((id) => (id.startsWith(`${s.instance}-`) ? id.slice(s.instance.length + 1) : id)) .filter((node): node is string => Boolean(node))), ...((s.nodes ?? []).filter((node): node is string => Boolean(node))), ]; const normalizedNodes = Array.from( new Set( nodeCandidates .map((node) => node.trim()) .filter((node) => node && node !== 'shared' && node !== 'cluster'), ), ); const nodesForStorage = normalizedNodes.length > 0 ? normalizedNodes : [s.node].map((node) => node?.trim()).filter((node): node is string => Boolean(node)); nodesForStorage.forEach((nodeName) => { const key = `${s.instance}-${nodeName}`; if (!groups[key]) groups[key] = []; groups[key].push({ ...s, node: nodeName }); }); return; } const key = `${s.instance}-${s.node}`; if (!groups[key]) groups[key] = []; groups[key].push(s); }); return groups; } else { // Group by storage name - show all storage as-is for maximum compatibility const groups: Record = {}; storage.forEach((s) => { if (!groups[s.name]) groups[s.name] = []; groups[s.name].push(s); }); return groups; } }); const resetFilters = () => { setSearchTerm(''); setSelectedNode(null); setViewMode('node'); setSortKey('name'); setSortDirection('asc'); setStatusFilter('all'); }; // Handle keyboard shortcuts let searchInputRef: HTMLInputElement | undefined; createEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if user is typing in an input const target = e.target as HTMLElement; const isInputField = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.contentEditable === 'true'; // Escape key behavior if (e.key === 'Escape') { // Clear search and reset filters if ( searchTerm().trim() || selectedNode() || viewMode() !== 'node' || statusFilter() !== 'all' ) { resetFilters(); // Blur the search input if it's focused if (searchInputRef && document.activeElement === searchInputRef) { searchInputRef.blur(); } } } else if (!isInputField && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { // If it's a printable character and user is not in an input field // Focus the search input and let the character be typed if (searchInputRef) { searchInputRef.focus(); } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }); const handleNodeSelect = (nodeId: string | null) => { setSelectedNode(nodeId); }; return (
{/* Node Selector */} {/* Tab Toggle */}
{/* Show Storage Filter only for pools */} (searchInputRef = el)} /> {/* Show simple search for disks */}
setSearchTerm(e.currentTarget.value)} ref={(el) => (searchInputRef = el)} class="w-full pl-9 pr-3 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" />
{/* Loading State */}
} title="Loading storage data..." description="Connecting to monitoring service" /> {/* Disconnected State */} } title="Connection lost" description={ reconnecting() ? 'Attempting to reconnect…' : 'Unable to connect to the backend server' } tone="danger" actions={ !reconnecting() ? ( ) : undefined } /> {/* Helpful hint for no PVE nodes but still show content */} n.type === 'pve').length === 0 && sortedStorage().length === 0 && searchTerm().trim() === '' } > } title="No storage configured" description="Add a Proxmox VE or PBS node in the Settings tab to start monitoring storage." actions={ } /> {/* Conditional rendering based on tab */} {/* No results found message for storage pools */} } title="No storage found" description={`No storage matches your search "${searchTerm()}"`} /> {/* Storage Table - shows for both PVE and PBS storage */} 0}>
{ // Sort by friendly node name for display when in node mode if (viewMode() === 'node') { const nodeA = nodeByInstance()[instanceIdA]; const nodeB = nodeByInstance()[instanceIdB]; const labelA = nodeA ? getNodeDisplayName(nodeA) : instanceIdA; const labelB = nodeB ? getNodeDisplayName(nodeB) : instanceIdB; return labelA.localeCompare(labelB); } // Sort by storage name when in storage mode return instanceIdA.localeCompare(instanceIdB); })} > {([groupKey, storages]) => { const node = viewMode() === 'node' ? nodeByInstance()[groupKey] : null; return ( <> {/* Group Header */} {(validNode) => } {/* Storage Rows */} }> {(storage) => { const isDisabled = storage.status !== 'available'; const pbsNamesDisplay = createMemo(() => { const names = storage.pbsNames?.filter( (name): name is string => Boolean(name), ); if (!names || names.length === 0) return ''; return [...names].sort((a, b) => a.localeCompare(b)).join(', '); }); const nodeListDisplay = createMemo(() => { const nodes = storage.nodes && storage.nodes.length > 0 ? storage.nodes.filter((node): node is string => Boolean(node)) : [storage.node].filter((node): node is string => Boolean(node)); return nodes.join(', '); }); const alertStyles = createMemo(() => getAlertStyles( storage.id || `${storage.instance}-${storage.node}-${storage.name}`, activeAlerts, alertsEnabled(), ), ); const parentNodeOnline = createMemo(() => { if (viewMode() === 'node' && node) { return node.status === 'online' && (node.uptime || 0) > 0; } return true; }); const showAlertHighlight = createMemo( () => alertStyles().hasUnacknowledgedAlert && parentNodeOnline(), ); const isCephStorage = createMemo(() => isCephType(storage.type)); const canExpand = createMemo(() => isCephStorage()); const zfsPool = storage.zfsPool; const storageRowId = createMemo( () => storage.id || `${storage.instance}-${storage.node}-${storage.name}`, ); const cephCluster = createMemo( () => cephClusterByInstance()[storage.instance], ); const cephInstanceStorages = createMemo(() => (state.storage || []).filter( (item) => item.instance === storage.instance && isCephType(item.type), ), ); const cephHealthLabel = createMemo(() => { const health = (cephCluster()?.health || '').toUpperCase(); if (!health || health === 'HEALTH_UNKNOWN') { return ''; } return getCephHealthLabel(health); }); const cephHealthClass = createMemo(() => { const health = (cephCluster()?.health || '').toUpperCase(); if (!health || health === 'HEALTH_UNKNOWN') { return ''; } return getCephHealthStyles(health); }); const drawerDisabled = createMemo( () => isDisabled || !parentNodeOnline(), ); const cephSummaryText = createMemo(() => { const cluster = cephCluster(); const parts: string[] = []; if (cluster && Number.isFinite(cluster.totalBytes)) { const total = Math.max(0, cluster.totalBytes || 0); const used = Math.max(0, cluster.usedBytes || 0); const percent = total > 0 ? (used / total) * 100 : 0; parts.push( `${formatBytes(used, 0)} / ${formatBytes(total, 0)} (${formatPercent(percent)})`, ); if ( Number.isFinite(cluster.numOsds) && Number.isFinite(cluster.numOsdsUp) ) { parts.push(`OSDs ${cluster.numOsdsUp}/${cluster.numOsds}`); } if (Number.isFinite(cluster.numPGs) && cluster.numPGs > 0) { parts.push(`PGs ${cluster.numPGs.toLocaleString()}`); } } else { const storages = cephInstanceStorages(); if (storages.length > 0) { const totals = storages.reduce( (acc, item) => { acc.total += Math.max(0, item.total || 0); acc.used += Math.max(0, item.used || 0); return acc; }, { total: 0, used: 0 }, ); if (totals.total > 0) { const percent = (totals.used / totals.total) * 100; parts.push( `${formatBytes(totals.used, 0)} / ${formatBytes(totals.total, 0)} (${formatPercent(percent)})`, ); } } } return parts.join(' • '); }); const cephPoolsText = createMemo(() => { const cluster = cephCluster(); if (cluster && cluster.pools && cluster.pools.length > 0) { return cluster.pools .slice(0, 2) .map((pool) => { if (!pool) return ''; const total = Math.max(1, pool.storedBytes + pool.availableBytes); const percent = total > 0 ? (pool.storedBytes / total) * 100 : 0; return `${pool.name}: ${formatPercent(percent)}`; }) .filter(Boolean) .join(', '); } const storages = cephInstanceStorages(); if (storages.length === 0) { return ''; } return storages .slice(0, 2) .map((item) => { const total = Math.max(1, item.total || 0); const used = Math.max(0, item.used || 0); const percent = total > 0 ? (used / total) * 100 : 0; return `${item.name}: ${formatPercent(percent)}`; }) .filter(Boolean) .join(', '); }); const cephHealthMessage = createMemo(() => { const cluster = cephCluster(); if (cluster?.healthMessage) { return cluster.healthMessage; } if (cluster && cluster.health && cluster.health !== 'HEALTH_UNKNOWN') { return ''; } if (cephInstanceStorages().length > 0) { return 'Derived from storage metrics – live Ceph telemetry unavailable.'; } return ''; }); const isExpanded = createMemo( () => expandedStorage() === storageRowId(), ); const hasAcknowledgedOnlyAlert = createMemo( () => alertStyles().hasAcknowledgedOnlyAlert && parentNodeOnline(), ); const rowClass = createMemo(() => { const classes = [ 'transition-all duration-200', 'hover:bg-gray-50 dark:hover:bg-gray-700/30', 'hover:shadow-sm', ]; if (showAlertHighlight()) { classes.push( alertStyles().severity === 'critical' ? 'bg-red-50 dark:bg-red-950/30' : 'bg-yellow-50 dark:bg-yellow-950/20', ); } else if (hasAcknowledgedOnlyAlert()) { classes.push('bg-gray-50/40 dark:bg-gray-800/40'); } if (isDisabled || !parentNodeOnline()) { classes.push('opacity-60'); } if (canExpand()) { classes.push('cursor-pointer'); } if (canExpand() && isExpanded()) { classes.push('bg-gray-50 dark:bg-gray-800/40'); } return classes.join(' '); }); const rowStyle = createMemo(() => { if (showAlertHighlight()) { const color = alertStyles().severity === 'critical' ? '#ef4444' : '#eab308'; return { 'box-shadow': `inset 4px 0 0 0 ${color}`, }; } if (hasAcknowledgedOnlyAlert()) { return { 'box-shadow': 'inset 4px 0 0 0 rgba(156, 163, 175, 0.8)', }; } return {} as Record; }); const firstCellHasIndicator = createMemo( () => showAlertHighlight() || hasAcknowledgedOnlyAlert(), ); const firstCellClass = createMemo(() => { if (viewMode() === 'node') { return firstCellHasIndicator() ? 'p-0.5 pl-7 pr-1.5' : 'p-0.5 pl-8 pr-1.5'; } return firstCellHasIndicator() ? 'p-0.5 pl-3 pr-1.5' : 'p-0.5 pl-3 pr-1.5'; }); const toggleDrawer = () => { if (!canExpand()) return; setExpandedStorage((prev) => prev === storageRowId() ? null : storageRowId(), ); }; return ( <> 0 || storage.zfsPool.writeErrors > 0 || storage.zfsPool.checksumErrors > 0) } > ); }} ); }}
Storage Usage Total
{storage.name} {/* ZFS Health Map */} 0}>
{/* ZFS Health Badge */} {zfsPool?.state} {/* ZFS Error Badge */} 0 || zfsPool.writeErrors > 0 || zfsPool.checksumErrors > 0) } > ERRORS ({pbsNamesDisplay()}) ({nodeListDisplay()}) {cephHealthLabel()}
{formatBytes(storage.total || 0, 0)}
Ceph Cluster {cephCluster()?.name || storage.instance} {cephHealthLabel()}
{cephSummaryText()}
{cephHealthMessage()}
Pools
No pool data available
} >
{cephPoolsText()}
ZFS Pool Status: {storage.zfsPool!.state} 0 || storage.zfsPool!.writeErrors > 0 || storage.zfsPool!.checksumErrors > 0 } > Errors: {storage.zfsPool!.readErrors} read,{' '} {storage.zfsPool!.writeErrors} write,{' '} {storage.zfsPool!.checksumErrors} checksum
d.state !== 'ONLINE' || d.readErrors > 0 || d.writeErrors > 0 || d.checksumErrors > 0, )} >
d.state !== 'ONLINE' || d.readErrors > 0 || d.writeErrors > 0 || d.checksumErrors > 0, )} > {(device) => (
Device {device.name}: {device.state} 0 || device.writeErrors > 0 || device.checksumErrors > 0 } > ({device.readErrors}R/{device.writeErrors} W/{device.checksumErrors}C errors) {(message) => ( {message()} )}
)}
{/* Physical Disks Tab */} ); }; export default Storage;