- UnifiedBackups: memoize parsed search filters, debounce search input - Storage: debounce search input to prevent jank during rapid typing
1381 lines
64 KiB
TypeScript
1381 lines
64 KiB
TypeScript
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<string | null>(null);
|
||
const [expandedStorage, setExpandedStorage] = createSignal<string | null>(null);
|
||
const [sortKey, setSortKey] = usePersistentSignal<StorageSortKey>('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<string, typeof state.nodes[0]> = {};
|
||
(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<CephCluster[]>(() => {
|
||
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<string>;
|
||
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<string>(),
|
||
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<Record<string, CephCluster>>(() => {
|
||
const map: Record<string, CephCluster> = {};
|
||
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<string, StorageType[]> = {};
|
||
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<string, StorageType[]> = {};
|
||
|
||
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 (
|
||
<div class="space-y-3">
|
||
<ProxmoxSectionNav current="storage" />
|
||
|
||
{/* Node Selector */}
|
||
<UnifiedNodeSelector
|
||
currentTab="storage"
|
||
globalTemperatureMonitoringEnabled={state.temperatureMonitoringEnabled}
|
||
onNodeSelect={handleNodeSelect}
|
||
filteredStorage={sortedStorage()}
|
||
searchTerm={searchTerm()}
|
||
/>
|
||
|
||
{/* Tab Toggle */}
|
||
<div class="mb-4">
|
||
<nav class="flex items-center gap-4" aria-label="Storage tabs">
|
||
<button
|
||
onClick={() => setTabView('pools')}
|
||
type="button"
|
||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'pools'
|
||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||
}`}
|
||
>
|
||
Storage Pools
|
||
</button>
|
||
<button
|
||
onClick={() => setTabView('disks')}
|
||
type="button"
|
||
class={`inline-flex items-center px-2 sm:px-3 py-1 text-sm font-medium border-b-2 border-transparent text-gray-600 dark:text-gray-400 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-gray-900 ${tabView() === 'disks'
|
||
? 'text-blue-600 dark:text-blue-300 border-blue-500 dark:border-blue-400'
|
||
: 'hover:text-blue-500 dark:hover:text-blue-300 hover:border-blue-300/70 dark:hover:border-blue-500/50'
|
||
}`}
|
||
>
|
||
Physical Disks
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Show Storage Filter only for pools */}
|
||
<Show when={tabView() === 'pools'}>
|
||
<StorageFilter
|
||
search={searchTerm}
|
||
setSearch={setSearchTerm}
|
||
groupBy={viewMode}
|
||
setGroupBy={setViewMode}
|
||
sortOptions={sortKeyOptions}
|
||
sortKey={sortKey}
|
||
setSortKey={setSortKey}
|
||
sortDirection={sortDirection}
|
||
setSortDirection={setSortDirection}
|
||
statusFilter={statusFilter}
|
||
setStatusFilter={setStatusFilter}
|
||
searchInputRef={(el) => (searchInputRef = el)}
|
||
/>
|
||
</Show>
|
||
|
||
{/* Show simple search for disks */}
|
||
<Show when={tabView() === 'disks'}>
|
||
<Card class="mb-3" padding="sm">
|
||
<div class="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="Search disks by model, path, or serial..."
|
||
value={searchTerm()}
|
||
onInput={(e) => 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"
|
||
/>
|
||
<svg
|
||
class="absolute left-3 top-2 h-4 w-4 text-gray-400 dark:text-gray-500"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
</Card>
|
||
</Show>
|
||
|
||
{/* Loading State */}
|
||
<Show when={connected() && !initialDataReceived()}>
|
||
<Card padding="lg">
|
||
<EmptyState
|
||
icon={
|
||
<div class="mx-auto flex h-12 w-12 items-center justify-center">
|
||
<svg class="h-8 w-8 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
|
||
<circle
|
||
class="opacity-25"
|
||
cx="12"
|
||
cy="12"
|
||
r="10"
|
||
stroke="currentColor"
|
||
stroke-width="4"
|
||
/>
|
||
<path
|
||
class="opacity-75"
|
||
fill="currentColor"
|
||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||
/>
|
||
</svg>
|
||
</div>
|
||
}
|
||
title="Loading storage data..."
|
||
description="Connecting to monitoring service"
|
||
/>
|
||
</Card>
|
||
</Show>
|
||
|
||
{/* Disconnected State */}
|
||
<Show when={!connected()}>
|
||
<Card padding="lg" tone="danger">
|
||
<EmptyState
|
||
icon={
|
||
<svg
|
||
class="h-12 w-12 text-red-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||
/>
|
||
</svg>
|
||
}
|
||
title="Connection lost"
|
||
description={
|
||
reconnecting()
|
||
? 'Attempting to reconnect…'
|
||
: 'Unable to connect to the backend server'
|
||
}
|
||
tone="danger"
|
||
actions={
|
||
!reconnecting() ? (
|
||
<button
|
||
onClick={() => reconnect()}
|
||
class="mt-2 inline-flex items-center px-4 py-2 text-xs font-medium rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
|
||
>
|
||
Reconnect now
|
||
</button>
|
||
) : undefined
|
||
}
|
||
/>
|
||
</Card>
|
||
</Show>
|
||
|
||
{/* Helpful hint for no PVE nodes but still show content */}
|
||
<Show
|
||
when={
|
||
connected() &&
|
||
initialDataReceived() &&
|
||
(state.nodes || []).filter((n) => n.type === 'pve').length === 0 &&
|
||
sortedStorage().length === 0 &&
|
||
searchTerm().trim() === ''
|
||
}
|
||
>
|
||
<Card padding="lg">
|
||
<EmptyState
|
||
icon={
|
||
<svg
|
||
class="h-12 w-12 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||
/>
|
||
</svg>
|
||
}
|
||
title="No storage configured"
|
||
description="Add a Proxmox VE or PBS node in the Settings tab to start monitoring storage."
|
||
actions={
|
||
<button
|
||
type="button"
|
||
onClick={() => navigate('/settings')}
|
||
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||
>
|
||
Go to Settings
|
||
</button>
|
||
}
|
||
/>
|
||
</Card>
|
||
</Show>
|
||
|
||
{/* Conditional rendering based on tab */}
|
||
<Show when={tabView() === 'pools'}>
|
||
{/* No results found message for storage pools */}
|
||
<Show
|
||
when={
|
||
connected() &&
|
||
initialDataReceived() &&
|
||
sortedStorage().length === 0 &&
|
||
searchTerm().trim() !== ''
|
||
}
|
||
>
|
||
<Card padding="lg">
|
||
<EmptyState
|
||
icon={
|
||
<svg
|
||
class="h-12 w-12 text-gray-400"
|
||
fill="none"
|
||
viewBox="0 0 24 24"
|
||
stroke="currentColor"
|
||
>
|
||
<path
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
stroke-width="2"
|
||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||
/>
|
||
</svg>
|
||
}
|
||
title="No storage found"
|
||
description={`No storage matches your search "${searchTerm()}"`}
|
||
/>
|
||
</Card>
|
||
</Show>
|
||
|
||
{/* Storage Table - shows for both PVE and PBS storage */}
|
||
<Show when={connected() && initialDataReceived() && sortedStorage().length > 0}>
|
||
<ComponentErrorBoundary name="Storage Table">
|
||
<Card padding="none" tone="glass" class="mb-4 overflow-hidden">
|
||
<div class="overflow-x-auto" style="scrollbar-width: none; -ms-overflow-style: none;">
|
||
<style>{`
|
||
.overflow-x-auto::-webkit-scrollbar { display: none; }
|
||
`}</style>
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr class="bg-gray-50 dark:bg-gray-700/50 text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-auto">
|
||
Storage
|
||
</th>
|
||
<th class="hidden md:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||
Type
|
||
</th>
|
||
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[15%]">
|
||
Content
|
||
</th>
|
||
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||
Status
|
||
</th>
|
||
<Show when={viewMode() === 'node'}>
|
||
<th class="hidden lg:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[8%]">
|
||
Shared
|
||
</th>
|
||
</Show>
|
||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[25%] min-w-[150px]">
|
||
Usage
|
||
</th>
|
||
<th class="hidden sm:table-cell px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||
Free
|
||
</th>
|
||
<th class="px-2 py-1.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider w-[10%]">
|
||
Total
|
||
</th>
|
||
<th class="px-2 py-1.5 w-8"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||
<For
|
||
each={Object.entries(groupedStorage()).sort(([instanceIdA], [instanceIdB]) => {
|
||
// 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 */}
|
||
<Show when={viewMode() === 'node' && node}>
|
||
{(validNode) => <NodeGroupHeader node={validNode()} colspan={9} renderAs="tr" />}
|
||
</Show>
|
||
|
||
{/* Storage Rows */}
|
||
<For each={storages} fallback={<></>}>
|
||
{(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<string, string>;
|
||
});
|
||
|
||
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 (
|
||
<>
|
||
<tr
|
||
class={`${rowClass()} transition-colors`}
|
||
style={rowStyle()}
|
||
onClick={toggleDrawer}
|
||
aria-expanded={canExpand() && isExpanded() ? 'true' : 'false'}
|
||
>
|
||
<td class={`${firstCellClass()} align-middle`}>
|
||
<div class="flex items-center gap-2 min-w-0">
|
||
<span
|
||
class={`text-sm font-medium text-gray-900 dark:text-gray-100 truncate ${canExpand() ? 'max-w-[180px]' : 'max-w-[200px]'
|
||
}`}
|
||
title={storage.name}
|
||
>
|
||
{storage.name}
|
||
</span>
|
||
{/* ZFS Health Map */}
|
||
<Show when={zfsPool && zfsPool.devices && zfsPool.devices.length > 0}>
|
||
<div class="mx-1.5">
|
||
<ZFSHealthMap pool={zfsPool!} />
|
||
</div>
|
||
</Show>
|
||
{/* ZFS Health Badge */}
|
||
<Show when={zfsPool && zfsPool.state !== 'ONLINE'}>
|
||
<span
|
||
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${zfsPool?.state === 'DEGRADED'
|
||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||
}`}
|
||
>
|
||
{zfsPool?.state}
|
||
</span>
|
||
</Show>
|
||
{/* ZFS Error Badge */}
|
||
<Show
|
||
when={
|
||
zfsPool &&
|
||
(zfsPool.readErrors > 0 ||
|
||
zfsPool.writeErrors > 0 ||
|
||
zfsPool.checksumErrors > 0)
|
||
}
|
||
>
|
||
<span class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
|
||
ERRORS
|
||
</span>
|
||
</Show>
|
||
<Show when={viewMode() === 'storage'}>
|
||
<Show when={storage.pbsNames}>
|
||
<span
|
||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap truncate max-w-[240px]"
|
||
title={pbsNamesDisplay()}
|
||
>
|
||
({pbsNamesDisplay()})
|
||
</span>
|
||
</Show>
|
||
<Show when={!storage.pbsNames}>
|
||
<span
|
||
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap truncate max-w-[240px]"
|
||
title={nodeListDisplay()}
|
||
>
|
||
({nodeListDisplay()})
|
||
</span>
|
||
</Show>
|
||
</Show>
|
||
<Show when={isCephStorage() && cephHealthLabel()}>
|
||
<span
|
||
class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${cephHealthClass()}`}
|
||
title={cephCluster()?.healthMessage}
|
||
>
|
||
{cephHealthLabel()}
|
||
</span>
|
||
</Show>
|
||
</div>
|
||
</td>
|
||
<td class="hidden md:table-cell p-0.5 px-1.5">
|
||
<span class="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||
{storage.type}
|
||
</span>
|
||
</td>
|
||
<td class="hidden lg:table-cell p-0.5 px-1.5">
|
||
<span
|
||
class="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap truncate max-w-[220px]"
|
||
title={storage.content || '-'}
|
||
>
|
||
{storage.content || '-'}
|
||
</span>
|
||
</td>
|
||
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||
<span
|
||
class={`${storage.status === 'available'
|
||
? 'text-green-600 dark:text-green-400'
|
||
: 'text-red-600 dark:text-red-400'
|
||
}`}
|
||
>
|
||
{storage.status || 'unknown'}
|
||
</span>
|
||
</td>
|
||
<Show when={viewMode() === 'node'}>
|
||
<td class="hidden lg:table-cell p-0.5 px-1.5">
|
||
<span class="text-xs text-gray-600 dark:text-gray-400">
|
||
{storage.shared ? '✓' : '-'}
|
||
</span>
|
||
</td>
|
||
</Show>
|
||
|
||
<td class="p-0.5 px-1.5">
|
||
<EnhancedStorageBar
|
||
used={storage.used || 0}
|
||
total={storage.total || 0}
|
||
free={storage.free || 0}
|
||
zfsPool={storage.zfsPool}
|
||
/>
|
||
</td>
|
||
<td class="hidden sm:table-cell p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||
{formatBytes(storage.free || 0, 0)}
|
||
</td>
|
||
<td class="p-0.5 px-1.5 text-xs whitespace-nowrap">
|
||
{formatBytes(storage.total || 0, 0)}
|
||
</td>
|
||
<td class="p-0.5 px-1.5"></td>
|
||
</tr>
|
||
<Show when={isCephStorage() && isExpanded()}>
|
||
<tr
|
||
class={`text-[11px] border-t border-gray-200 dark:border-gray-700 ${drawerDisabled()
|
||
? 'bg-gray-100/70 text-gray-400 dark:bg-gray-900/30 dark:text-gray-500'
|
||
: 'bg-gray-50/60 text-gray-700 dark:bg-gray-900/30 dark:text-gray-300'
|
||
}`}
|
||
>
|
||
<td colSpan={9} class="px-4 py-3">
|
||
<div
|
||
class={`grid gap-3 md:grid-cols-2 xl:grid-cols-2 ${drawerDisabled() ? 'opacity-60 pointer-events-none' : ''
|
||
}`}
|
||
>
|
||
<div class="rounded-lg border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-600/60 dark:bg-gray-900/30">
|
||
<div class="flex items-center gap-2 text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||
<span>Ceph Cluster</span>
|
||
<span>{cephCluster()?.name || storage.instance}</span>
|
||
<Show when={cephHealthLabel()}>
|
||
<span class={`px-1.5 py-0.5 rounded text-[10px] font-medium ${cephHealthClass()}`}>
|
||
{cephHealthLabel()}
|
||
</span>
|
||
</Show>
|
||
</div>
|
||
<Show when={cephSummaryText()}>
|
||
<div class="mt-2 text-[12px] text-gray-600 dark:text-gray-300">
|
||
{cephSummaryText()}
|
||
</div>
|
||
</Show>
|
||
<Show when={cephHealthMessage()}>
|
||
<div class="mt-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||
{cephHealthMessage()}
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
<div class="rounded-lg border border-gray-200 bg-white/80 p-4 shadow-sm dark:border-gray-600/60 dark:bg-gray-900/30 text-gray-600 dark:text-gray-300">
|
||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||
Pools
|
||
</div>
|
||
<Show
|
||
when={cephPoolsText()}
|
||
fallback={
|
||
<div class="mt-2 text-[12px] text-gray-500 dark:text-gray-400">
|
||
No pool data available
|
||
</div>
|
||
}
|
||
>
|
||
<div class="mt-2 text-[12px]">{cephPoolsText()}</div>
|
||
</Show>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
<Show
|
||
when={
|
||
storage.zfsPool &&
|
||
(storage.zfsPool.state !== 'ONLINE' ||
|
||
storage.zfsPool.readErrors > 0 ||
|
||
storage.zfsPool.writeErrors > 0 ||
|
||
storage.zfsPool.checksumErrors > 0)
|
||
}
|
||
>
|
||
<tr class="bg-yellow-50 dark:bg-yellow-950/20 border-l-4 border-yellow-500">
|
||
<td colspan="9" class="p-2">
|
||
<div class="text-xs space-y-1">
|
||
<div class="flex items-center gap-2">
|
||
<span class="font-semibold text-yellow-700 dark:text-yellow-400">
|
||
ZFS Pool Status:
|
||
</span>
|
||
<span
|
||
class={`px-1.5 py-0.5 rounded text-xs font-medium ${storage.zfsPool!.state === 'ONLINE'
|
||
? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
|
||
: storage.zfsPool!.state === 'DEGRADED'
|
||
? 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300'
|
||
: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'
|
||
}`}
|
||
>
|
||
{storage.zfsPool!.state}
|
||
</span>
|
||
<Show
|
||
when={
|
||
storage.zfsPool!.readErrors > 0 ||
|
||
storage.zfsPool!.writeErrors > 0 ||
|
||
storage.zfsPool!.checksumErrors > 0
|
||
}
|
||
>
|
||
<span class="text-red-600 dark:text-red-400">
|
||
Errors: {storage.zfsPool!.readErrors} read,{' '}
|
||
{storage.zfsPool!.writeErrors} write,{' '}
|
||
{storage.zfsPool!.checksumErrors} checksum
|
||
</span>
|
||
</Show>
|
||
</div>
|
||
<Show
|
||
when={storage.zfsPool!.devices.some(
|
||
(d) =>
|
||
d.state !== 'ONLINE' ||
|
||
d.readErrors > 0 ||
|
||
d.writeErrors > 0 ||
|
||
d.checksumErrors > 0,
|
||
)}
|
||
>
|
||
<div class="ml-4 space-y-0.5">
|
||
<For
|
||
each={storage.zfsPool!.devices.filter(
|
||
(d) =>
|
||
d.state !== 'ONLINE' ||
|
||
d.readErrors > 0 ||
|
||
d.writeErrors > 0 ||
|
||
d.checksumErrors > 0,
|
||
)}
|
||
>
|
||
{(device) => (
|
||
<div class="flex items-center gap-2 text-xs">
|
||
<span class="text-gray-600 dark:text-gray-400">
|
||
Device {device.name}:
|
||
</span>
|
||
<span
|
||
class={`${device.state !== 'ONLINE' ? 'text-red-600 dark:text-red-400' : 'text-yellow-600 dark:text-yellow-400'}`}
|
||
>
|
||
{device.state}
|
||
<Show
|
||
when={
|
||
device.readErrors > 0 ||
|
||
device.writeErrors > 0 ||
|
||
device.checksumErrors > 0
|
||
}
|
||
>
|
||
<span class="ml-1">
|
||
({device.readErrors}R/{device.writeErrors}
|
||
W/{device.checksumErrors}C errors)
|
||
</span>
|
||
</Show>
|
||
<Show when={device.message?.trim()}>
|
||
{(message) => (
|
||
<span class="ml-1 text-gray-500 dark:text-gray-400">
|
||
{message()}
|
||
</span>
|
||
)}
|
||
</Show>
|
||
</span>
|
||
</div>
|
||
)}
|
||
</For>
|
||
</div>
|
||
</Show>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</Show>
|
||
</>
|
||
);
|
||
}}
|
||
</For>
|
||
</>
|
||
);
|
||
}}
|
||
</For>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</ComponentErrorBoundary>
|
||
</Show>
|
||
</Show>
|
||
|
||
{/* Physical Disks Tab */}
|
||
<Show when={tabView() === 'disks'}>
|
||
<DiskList
|
||
disks={state.physicalDisks || []}
|
||
selectedNode={selectedNode()}
|
||
searchTerm={searchTerm()}
|
||
/>
|
||
</Show>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Storage;
|