From 556c445d721293de1b64a19a331d3b2e2df62325 Mon Sep 17 00:00:00 2001 From: hayzamjs Date: Wed, 2 Apr 2025 20:02:45 +0400 Subject: [PATCH] ui: zfs: improvements to disks/partition selection --- pkg/disk/gpart.go | 10 +- web/package-lock.json | 8 +- web/package.json | 2 +- web/src/lib/utils/arr.ts | 3 + web/src/lib/utils/dnd.ts | 190 ++++----- .../routes/[node]/storage/zfs/+page.svelte | 383 ++++++++++-------- 6 files changed, 322 insertions(+), 274 deletions(-) create mode 100644 web/src/lib/utils/arr.ts diff --git a/pkg/disk/gpart.go b/pkg/disk/gpart.go index 44df53af..e88bf3d9 100644 --- a/pkg/disk/gpart.go +++ b/pkg/disk/gpart.go @@ -47,7 +47,7 @@ func DestroyDisk(device string) error { return nil } -func CreatePartition(device string, size uint64) error { +func CreatePartition(device string, size uint64, ptype string) error { err := CheckDevice(device) if err != nil { @@ -59,7 +59,11 @@ func CreatePartition(device string, size uint64) error { return fmt.Errorf("size must be at least 1MB") } - output, err := utils.RunCommand("gpart", "add", "-t", "freebsd-zfs", "-s", fmt.Sprintf("%dMB", mbytes), device) + if ptype == "" { + ptype = "freebsd-zfs" + } + + output, err := utils.RunCommand("gpart", "add", "-t", ptype, "-s", fmt.Sprintf("%dMB", mbytes), device) if err != nil { return fmt.Errorf("error creating partition on disk %s: %v, output: %s", device, err, output) } @@ -91,7 +95,7 @@ func CreatePartitions(device string, sizes []uint64) error { } for _, size := range sizes { - err = CreatePartition(device, size) + err = CreatePartition(device, size, "") if err != nil { return err diff --git a/web/package-lock.json b/web/package-lock.json index a9f7ac85..bfbaaef3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,7 +37,7 @@ "@eslint/js": "^9.18.0", "@iconify/svelte": "^4.2.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.19.2", + "@sveltejs/kit": "^2.20.3", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", @@ -1856,9 +1856,9 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.19.2", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.19.2.tgz", - "integrity": "sha512-OkW7MMGkjXtdfqdHWlyPozh/Ct1X3pthXAKTSqHm+mwmvmTBASmPE6FhwlvUgsqlCceRYL+5QUGiIJfOy0xIjQ==", + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.3.tgz", + "integrity": "sha512-z1SQ8qra/kGY3DzarG7xc6XsbKm8UY3SnI82XLI3PqMYWbYj/LpjPWuAz9WA5EyLjFNLD7sOAOEW8Gt4yjr5Vg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web/package.json b/web/package.json index 19631ddd..bc04775b 100644 --- a/web/package.json +++ b/web/package.json @@ -19,7 +19,7 @@ "@eslint/js": "^9.18.0", "@iconify/svelte": "^4.2.0", "@sveltejs/adapter-static": "^3.0.8", - "@sveltejs/kit": "^2.19.2", + "@sveltejs/kit": "^2.20.3", "@sveltejs/vite-plugin-svelte": "^5.0.0", "@tailwindcss/container-queries": "^0.1.1", "@tailwindcss/forms": "^0.5.10", diff --git a/web/src/lib/utils/arr.ts b/web/src/lib/utils/arr.ts new file mode 100644 index 00000000..664e631a --- /dev/null +++ b/web/src/lib/utils/arr.ts @@ -0,0 +1,3 @@ +export function createEmptyArrayOfArrays(length: number): Array> { + return Array.from({ length }, () => []); +} diff --git a/web/src/lib/utils/dnd.ts b/web/src/lib/utils/dnd.ts index 2ed2cbbf..2d7c0c6c 100644 --- a/web/src/lib/utils/dnd.ts +++ b/web/src/lib/utils/dnd.ts @@ -9,116 +9,122 @@ */ export function draggable(node: HTMLElement, data: string) { - let state = data; + let state = data; - node.draggable = true; - node.style.cursor = 'grab'; + node.draggable = true; + node.style.cursor = 'grab'; - function handle_dragstart(e: DragEvent) { - if (!e.dataTransfer) return; - //const dataToTransfer = typeof state === 'string' ? state : state.toString(); - e.dataTransfer.setData('application/disk', state); - e.dataTransfer.setData('text/plain', state); - // Add this to make the drag image transparent if desired - // setTimeout(() => { - // node.style.opacity = '0.4'; - // }, 0); - } + function handle_dragstart(e: DragEvent) { + if (!e.dataTransfer) return; + //const dataToTransfer = typeof state === 'string' ? state : state.toString(); + e.dataTransfer.setData('application/disk', state); + e.dataTransfer.setData('text/plain', state); + // Add this to make the drag image transparent if desired + // setTimeout(() => { + // node.style.opacity = '0.4'; + // }, 0); + } - function handle_dragend(e: DragEvent) { - // Reset any visual changes - // node.style.opacity = '1'; - } + function handle_dragend(e: DragEvent) { + // Reset any visual changes + // node.style.opacity = '1'; + } - node.addEventListener('dragstart', handle_dragstart); - node.addEventListener('dragend', handle_dragend); + node.addEventListener('dragstart', handle_dragstart); + node.addEventListener('dragend', handle_dragend); - return { - update(data: string) { - state = data; - }, + return { + update(data: string) { + state = data; + }, - destroy() { - node.removeEventListener('dragstart', handle_dragstart); - node.removeEventListener('dragend', handle_dragend); - } - }; + destroy() { + node.removeEventListener('dragstart', handle_dragstart); + node.removeEventListener('dragend', handle_dragend); + } + }; } -export function dropzone(node: HTMLElement, options: { dropEffect?: 'move' | 'none' | 'copy' | 'link', dragover_class?: string, on_dropzone?: (data: string, e: DragEvent) => void }) { - console.log('options:', options); +export function dropzone( + node: HTMLElement, + options: { + dropEffect?: 'move' | 'none' | 'copy' | 'link'; + dragover_class?: string; + on_dropzone?: (data: string, e: DragEvent) => void; + } +) { + let state = { + dropEffect: 'move' as 'move' | 'none' | 'copy' | 'link', + dragover_class: 'droppable', + ...options + }; - let state = { - dropEffect: 'move' as 'move' | 'none' | 'copy' | 'link', - dragover_class: 'droppable', - ...options - }; + let dragCounter = 0; - // Track if we're currently dragging over this zone - let dragCounter = 0; + function handle_dragenter(e: DragEvent) { + e.preventDefault(); + dragCounter++; - function handle_dragenter(e: DragEvent) { - e.preventDefault(); - dragCounter++; + if (dragCounter === 1) { + node.classList.add(state.dragover_class); + } + } - if (dragCounter === 1) { - // Only add class when first entering - node.classList.add(state.dragover_class); - console.log('Adding dropzone class to', node); - } - } + function handle_dragleave(e: DragEvent) { + dragCounter--; - function handle_dragleave(e: DragEvent) { - dragCounter--; + if (dragCounter === 0) { + // Only remove class when fully leaving + node.classList.remove(state.dragover_class); + console.log('Removing dropzone class from', node); + } + } - if (dragCounter === 0) { - // Only remove class when fully leaving - node.classList.remove(state.dragover_class); - console.log('Removing dropzone class from', node); - } - } + function handle_dragover(e: DragEvent) { + e.preventDefault(); + if (!e.dataTransfer) return; + e.dataTransfer.dropEffect = state.dropEffect; + } - function handle_dragover(e: DragEvent) { - e.preventDefault(); - if (!e.dataTransfer) return; - e.dataTransfer.dropEffect = state.dropEffect; - } + function handle_drop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); - function handle_drop(e: DragEvent) { - e.preventDefault(); - e.stopPropagation(); // Stop event from bubbling up + dragCounter = 0; + node.classList.remove(state.dragover_class); - dragCounter = 0; // Reset counter - node.classList.remove(state.dragover_class); + if (!e.dataTransfer) return; + const data = e.dataTransfer.getData('text/plain'); + console.log('Dropped data:', data); - if (!e.dataTransfer) return; - const data = e.dataTransfer.getData('text/plain'); - console.log('Dropped data:', data); + if (typeof state.on_dropzone === 'function') { + state.on_dropzone(data, e); + } + } - if (typeof state.on_dropzone === 'function') { - state.on_dropzone(data, e); - } - } + node.addEventListener('dragenter', handle_dragenter); + node.addEventListener('dragleave', handle_dragleave); + node.addEventListener('dragover', handle_dragover); + node.addEventListener('drop', handle_drop); - node.addEventListener('dragenter', handle_dragenter); - node.addEventListener('dragleave', handle_dragleave); - node.addEventListener('dragover', handle_dragover); - node.addEventListener('drop', handle_drop); + return { + update(options: { + dropEffect?: 'move' | 'none' | 'copy' | 'link'; + dragover_class?: string; + on_dropzone?: (data: string, e: DragEvent) => void; + }) { + state = { + dropEffect: 'move', + dragover_class: 'droppable', + ...options + }; + }, - return { - update(options: { dropEffect?: 'move' | 'none' | 'copy' | 'link', dragover_class?: string, on_dropzone?: (data: string, e: DragEvent) => void }) { - state = { - dropEffect: 'move', - dragover_class: 'droppable', - ...options - }; - }, - - destroy() { - node.removeEventListener('dragenter', handle_dragenter); - node.removeEventListener('dragleave', handle_dragleave); - node.removeEventListener('dragover', handle_dragover); - node.removeEventListener('drop', handle_drop); - } - }; -} \ No newline at end of file + destroy() { + node.removeEventListener('dragenter', handle_dragenter); + node.removeEventListener('dragleave', handle_dragleave); + node.removeEventListener('dragover', handle_dragover); + node.removeEventListener('drop', handle_drop); + } + }; +} diff --git a/web/src/routes/[node]/storage/zfs/+page.svelte b/web/src/routes/[node]/storage/zfs/+page.svelte index c9531a85..598ad098 100644 --- a/web/src/routes/[node]/storage/zfs/+page.svelte +++ b/web/src/routes/[node]/storage/zfs/+page.svelte @@ -18,6 +18,7 @@ import { flip } from 'svelte/animate'; import { slide } from 'svelte/transition'; + import { createEmptyArrayOfArrays } from '$lib/utils/arr'; import { draggable, dropzone } from '$lib/utils/dnd'; import { untrack } from 'svelte'; @@ -33,6 +34,13 @@ type: string; } + interface DiskContainer { + id: string; + name: string; + size: number; + type: string; + } + let { data }: { data: Data } = $props(); const results = useQueries([ @@ -75,20 +83,23 @@ if (disk.Usage === 'Partitions') { for (const partition of disk.Partitions) { for (const pool of pools) { + let skip = false; + for (const vdev of pool.vdevs) { - if ( - vdev.name !== `/dev/${partition.name}` && - vdev.name !== partition.name && - partition.usage === 'ZFS' - ) { - unusedDisks.push({ - name: `/dev/${partition.name}`, - size: partition.size, - gpt: disk.GPT, - type: 'Partition' - }); + if (vdev.name.includes(partition.name)) { + skip = true; + continue; } } + + if (partition.usage === 'ZFS' && !skip) { + unusedDisks.push({ + name: `/dev/${partition.name}`, + size: partition.size, + gpt: disk.GPT, + type: 'Partition' + }); + } } } } @@ -99,12 +110,17 @@ let open: boolean = $state(false); let name: string = $state(''); - let vdevCount: number = $state(21); + let vdevCount: number = $state(1); let createEnabled: boolean = $state(false); - $effect(() => { - vdevCount = Math.max(1, Math.min(128, vdevCount)); - }); + function mapAvailable(): DiskContainer[] { + return useableDisks.map((disk) => ({ + id: disk.name, + name: disk.name, + size: disk.size, + type: disk.type + })); + } function close() { open = false; @@ -112,130 +128,85 @@ vdevCount = 1; } - // Convert useable disks to a format with IDs for drag and drop - let availableDisks = $state( - useableDisks.map((disk) => ({ - id: disk.name, - name: disk.name, - size: disk.size, - type: disk.type - })) - ); + let availableDisks: DiskContainer[] = $state(mapAvailable()); + let diskContainers: DiskContainer[][] | null = $state(null); - // Add dummy disks for testing if needed - if (availableDisks.length === 0) { - availableDisks = [ - // HDD disks - { id: 'disk1', name: '/dev/sda', size: 500 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk2', name: '/dev/sdb', size: 1000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk3', name: '/dev/sdc', size: 2000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk4', name: '/dev/sdd', size: 4000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk5', name: '/dev/sde', size: 8000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk6', name: '/dev/sdf', size: 10000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk7', name: '/dev/sdg', size: 12000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk8', name: '/dev/sdh', size: 14000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk9', name: '/dev/sdi', size: 16000 * 1024 * 1024 * 1024, type: 'HDD' }, - { id: 'disk10', name: '/dev/sdj', size: 18000 * 1024 * 1024 * 1024, type: 'HDD' }, - // SSD disks - { id: 'ssd1', name: '/dev/ssd0n1', size: 250 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd2', name: '/dev/ssd1n1', size: 500 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd3', name: '/dev/ssd2n1', size: 1000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd4', name: '/dev/ssd3n1', size: 2000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd5', name: '/dev/ssd4n1', size: 4000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd6', name: '/dev/ssd5n1', size: 8000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd7', name: '/dev/ssd6n1', size: 10000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd8', name: '/dev/ssd7n1', size: 12000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd9', name: '/dev/ssd8n1', size: 14000 * 1024 * 1024 * 1024, type: 'SSD' }, - { id: 'ssd10', name: '/dev/ssd9n1', size: 16000 * 1024 * 1024 * 1024, type: 'SSD' }, + $effect(() => { + vdevCount = Math.max(1, Math.min(128, vdevCount)); + untrack(() => { + console.log(useableDisks); + if (diskContainers === null) { + diskContainers = createEmptyArrayOfArrays(vdevCount); + } else { + // Create a new array with the new length first + const newContainers = Array(vdevCount).fill([]); - { id: 'nvm1', name: '/dev/nvme0n1', size: 250 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm2', name: '/dev/nvme1n1', size: 500 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm3', name: '/dev/nvme2n1', size: 1000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm4', name: '/dev/nvme3n1', size: 2000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm5', name: '/dev/nvme4n1', size: 4000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm6', name: '/dev/nvme5n1', size: 8000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm7', name: '/dev/nvme6n1', size: 10000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm8', name: '/dev/nvme7n1', size: 12000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm9', name: '/dev/nvme8n1', size: 14000 * 1024 * 1024 * 1024, type: 'NVMe' }, - { id: 'nvm10', name: '/dev/nvme9n1', size: 16000 * 1024 * 1024 * 1024, type: 'NVMe' } - ]; - } + // Then copy over existing containers up to the minimum of old and new length + const copyLength = Math.min(diskContainers.length, vdevCount); + for (let i = 0; i < copyLength; i++) { + newContainers[i] = [...diskContainers[i]]; // Create a new array reference + } - // Create containers for the disks - let diskContainers: { id: string; name: string; size: number; type: string }[][] = $state( - Array(vdevCount) - .fill(null) - .map((_, i) => []) - ); + diskContainers = newContainers; + } + }); + }); function handleDiskDrop(containerId: number, event: DragEvent) { - // event.preventDefault(); - console.log('Dropped disk in container', containerId); + event.preventDefault(); - const diskId = event.dataTransfer ? event.dataTransfer.getData('application/disk') : null; + const diskId = event.dataTransfer?.getData('application/disk'); + if (!diskId) return; - // Check if the disk is already in a container - let foundInContainer = -1; - let diskToMove = null; + let diskToMove: any = null; - // Look in containers first - for (let i = 0; i < diskContainers.length; i++) { - const diskIndex = diskContainers[i].findIndex((d) => d.id === diskId); - if (diskIndex !== -1) { - diskToMove = diskContainers[i][diskIndex]; - foundInContainer = i; - break; - } - } - - // Look in available disks if not found in containers - if (foundInContainer === -1) { - const diskIndex = availableDisks.findIndex((d) => d.id === diskId); - if (diskIndex !== -1) { - diskToMove = availableDisks[diskIndex]; - availableDisks = availableDisks.filter((d) => d.id !== diskId); - } - } else if (foundInContainer !== containerId) { - // Remove from original container if it's a different one - diskContainers[foundInContainer] = diskContainers[foundInContainer].filter( - (d) => d.id !== diskId + if (diskContainers) { + // Try to find and remove the disk from a container + const sourceContainerIndex = diskContainers.findIndex((container) => + container.some((disk) => disk.id === diskId) ); - } - // Add to target container if we found the disk - if (diskToMove && foundInContainer !== containerId) { - diskContainers[containerId] = [...diskContainers[containerId], diskToMove]; - } + if (sourceContainerIndex !== -1) { + const container = diskContainers[sourceContainerIndex]; + diskToMove = container.find((disk) => disk.id === diskId) || null; - // Enable create button if any disks are assigned - createEnabled = diskContainers.some((container) => container.length > 0); - } + // Remove it only if moving to a different container + if (sourceContainerIndex !== containerId) { + diskContainers[sourceContainerIndex] = container.filter((disk) => disk.id !== diskId); + } + } else { + // Try to find and remove from available disks + const diskIndex = availableDisks.findIndex((disk) => disk.id === diskId); + if (diskIndex !== -1) { + diskToMove = availableDisks[diskIndex]; + availableDisks.splice(diskIndex, 1); // Remove from availableDisks + } + } - // Function to handle removing a disk from a container - function returnDiskToPool(containerId: number, diskId: string) { - const container = diskContainers[containerId]; - const diskIndex = container.findIndex((d) => d.id === diskId); + // Add to target container if found and it's not already there + if (diskToMove && sourceContainerIndex !== containerId) { + diskContainers[containerId].push(diskToMove); + console.log(diskContainers); + } - if (diskIndex !== -1) { - const disk = container[diskIndex]; - diskContainers[containerId] = container.filter((d) => d.id !== diskId); - availableDisks = [...availableDisks, disk]; - - // Update create button state + // Enable create button if any container has disks createEnabled = diskContainers.some((container) => container.length > 0); } } - // $effect(() => { - // const oldContainers = [...diskContainers]; - // untrack(() => { - // diskContainers = Array(vdevCount) - // .fill(null) - // .map((_, i) => { - // return oldContainers[i] || []; - // }); - // }); - // }); + // Function to handle removing a disk from a container + function returnDiskToPool(containerId: number, diskId: string) { + if (diskContainers) { + const container = diskContainers[containerId]; + const diskIndex = container.findIndex((d) => d.id === diskId); + if (diskIndex !== -1) { + const disk = container[diskIndex]; + diskContainers[containerId] = container.filter((d) => d.id !== diskId); + availableDisks = [...availableDisks, disk]; + createEnabled = diskContainers.some((container) => container.length > 0); + } + } + } const raid = [ { value: 'mirror', label: 'Mirror' }, @@ -299,8 +270,8 @@ - Tab 1 - Tab 2 + Devices + Options @@ -316,57 +287,85 @@ - -
- +
{#each Array(vdevCount) as _, i} -
-
handleDiskDrop(i, event) - }} - > - {#if diskContainers[i].length === 0} -
- Drop disks here -
- {:else} -
- {#each diskContainers[i] as disk (disk.id)} -
- {#if disk.type === 'NVMe'} - - {:else} - - {/if} -
- {disk.name.split('/').pop()} -
- + {#if diskContainers} + {#if diskContainers[i]} +
+
+ handleDiskDrop(i, event) + }} + > + {#if diskContainers[i].length === 0} +
+ Drop disks here
- {/each} + {:else} +
+ {#each diskContainers[i] as disk (disk.id)} +
+ + {#if disk.type === 'SSD'} + + {:else if disk.type === 'NVMe'} + + {:else if disk.type === 'HDD'} + + {:else if disk.type === 'Partition'} + + {/if} +
+ {disk.name.split('/').pop()} +
+ +
+ {/each} +
+ {/if}
- {/if} -
-

VDEV {i + 1}

-
+

+ VDEV {i + 1} +

+
+ {/if} + {/if} {/each}
@@ -374,7 +373,7 @@
@@ -383,7 +382,7 @@ class="mt-1 w-full whitespace-nowrap rounded-md" orientation="horizontal" > -
+
{#each availableDisks.filter((disk) => disk.type === 'HDD') as disk (disk.id)}
@@ -398,7 +397,7 @@
{/each} - {#if availableDisks.length === 0} + {#if availableDisks.filter((disk) => disk.type === 'HDD').length === 0}
No available disks
@@ -415,7 +414,7 @@ class="mt-1 w-full whitespace-nowrap rounded-md " orientation="horizontal" > -
+
{#each availableDisks.filter((disk) => disk.type === 'SSD') as disk (disk.id)}
@@ -447,7 +446,7 @@ class="mt-1 w-full whitespace-nowrap rounded-md " orientation="horizontal" > -
+
{#each availableDisks.filter((disk) => disk.type === 'NVMe') as disk (disk.id)}
@@ -471,10 +470,46 @@
+ +
+ +
+ +
+ {#each availableDisks.filter((disk) => disk.type === 'Partition') as disk (disk.id)} +
+
+ +
+
+ {disk.name.split('/').pop()} +
+
+ {Math.round(disk.size / (1024 * 1024 * 1024))} GB +
+
+ {/each} + + {#if availableDisks.filter((disk) => disk.type === 'Partition').length === 0} +
+ No available partitions +
+ {/if} +
+
+
+
+