ui: zfs: fix alignments, add warning icon, docs: add demo video

This commit is contained in:
Hayzam Sherif
2025-04-01 21:56:48 +04:00
committed by hayzamjs
parent 556c445d72
commit 94144fedf7
5 changed files with 520 additions and 461 deletions
+2
View File
@@ -7,6 +7,8 @@
> [!WARNING]
> This project is still in development so expect breaking changes!
https://github.com/user-attachments/assets/bf727ef4-4316-4084-a61f-cb8ec978e43d
Sylve aims to be a lightweight, open-source virtualization platform for FreeBSD, leveraging Bhyve for VMs and Jails for containerization, with deep ZFS integration. It seeks to provide a streamlined, Proxmox-like experience tailored for FreeBSD environments. It's backend is written in Go and the frontend is written in Svelte (with Kit).
# Requirements
+54 -50
View File
@@ -3,58 +3,62 @@
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 72.22% 50.59%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.3rem;
}
:root {
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
--primary: 24 9.8% 10%;
--primary-foreground: 60 9.1% 97.8%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
--accent: 60 4.8% 95.9%;
--accent-foreground: 24 9.8% 10%;
--destructive: 0 72.22% 50.59%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
--ring: 20 14.3% 4.1%;
--radius: 0.3rem;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
.dark {
--background: 20 14.3% 4.1%;
--foreground: 60 9.1% 97.8%;
--card: 20 14.3% 4.1%;
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
--primary: 60 9.1% 97.8%;
--primary-foreground: 24 9.8% 10%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
--accent: 12 6.5% 15.1%;
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
--ring: 24 5.7% 82.9%;
}
}
@layer base {
* {
@apply border-border;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
body {
@apply bg-background text-foreground;
}
.droppable {
@apply border-2 border-neutral-300 dark:border-neutral-800;
}
}
+102 -100
View File
@@ -12,122 +12,124 @@ import type { Disk, DiskInfo, SmartAttribute, SmartCtl, SmartNVME } from '$lib/t
import type { Zpool } from '$lib/types/zfs/pool';
export async function simplifyDisks(disks: DiskInfo): Promise<Disk[]> {
const transformed: Disk[] = [];
for (const disk of disks) {
if (disk.size > 0) {
const o = {
Device: `/dev/${disk.device}`,
Type: disk.type,
Usage: disk.usage,
Size: disk.size,
GPT: disk.gpt,
Model: disk.model,
Serial: disk.serial,
Wearout:
typeof disk.wearOut === 'number' && !isNaN(disk.wearOut) ? `-` : `${disk.wearOut} %`,
'S.M.A.R.T.': 'Passed',
Partitions: disk.partitions,
SmartData: disk.smartData ?? null
};
const transformed: Disk[] = [];
for (const disk of disks) {
if (disk.size > 0) {
const o = {
Device: `/dev/${disk.device}`,
Type: disk.type,
Usage: disk.usage,
Size: disk.size,
GPT: disk.gpt,
Model: disk.model,
Serial: disk.serial,
Wearout:
typeof disk.wearOut === 'number' && !isNaN(disk.wearOut) ? `-` : `${disk.wearOut} %`,
'S.M.A.R.T.': 'Passed',
Partitions: disk.partitions,
SmartData: disk.smartData ?? null
};
o.Partitions.sort((a, b) => a.size - b.size);
transformed.push(o);
}
}
o.Partitions.sort((a, b) => a.size - b.size);
transformed.push(o);
}
}
transformed.sort((a, b) => {
if (a.Usage === 'Partitions' && b.Usage !== 'Partitions') {
return -1;
}
if (a.Usage !== 'Partitions' && b.Usage === 'Partitions') {
return 1;
}
return 0;
});
transformed.sort((a, b) => {
if (a.Usage === 'Partitions' && b.Usage !== 'Partitions') {
return -1;
}
if (a.Usage !== 'Partitions' && b.Usage === 'Partitions') {
return 1;
}
return 0;
});
return transformed;
return transformed;
}
export function parseSMART(disk: Disk): SmartAttribute | SmartAttribute[] {
if (disk.Type === 'NVMe') {
return {
'Available Spare': (disk.SmartData as SmartNVME).availableSpare,
'Available Spare Threshold': (disk.SmartData as SmartNVME).availableSpareThreshold,
'Controller Busy Time': (disk.SmartData as SmartNVME).controllerBusyTime,
'Critical Warning': (disk.SmartData as SmartNVME).criticalWarning,
'Critical Warning State': {
'Available Spare': (disk.SmartData as SmartNVME).criticalWarningState.availableSpare,
'Device Reliability': (disk.SmartData as SmartNVME).criticalWarningState.deviceReliability,
'Read Only': (disk.SmartData as SmartNVME).criticalWarningState.readOnly,
Temperature: (disk.SmartData as SmartNVME).criticalWarningState.temperature,
'Volatile Memory Backup': (disk.SmartData as SmartNVME).criticalWarningState
.volatileMemoryBackup
},
'Data Units Read': (disk.SmartData as SmartNVME).dataUnitsRead,
'Data Units Written': (disk.SmartData as SmartNVME).dataUnitsWritten,
'Error Info Log Entries': (disk.SmartData as SmartNVME).errorInfoLogEntries,
'Host Read Commands': (disk.SmartData as SmartNVME).hostReadCommands,
'Host Write Commands': (disk.SmartData as SmartNVME).hostWriteCommands,
'Media Errors': (disk.SmartData as SmartNVME).mediaErrors,
'Percentage Used': (disk.SmartData as SmartNVME).percentageUsed,
'Power Cycles': (disk.SmartData as SmartNVME).powerCycles,
'Power On Hours': (disk.SmartData as SmartNVME).powerOnHours,
Temperature: (disk.SmartData as SmartNVME).temperature,
'Temperature 1 Transition Count': (disk.SmartData as SmartNVME).temperature1TransitionCnt,
'Temperature 2 Transition Count': (disk.SmartData as SmartNVME).temperature2TransitionCnt,
'Total Time For Temperature 1': (disk.SmartData as SmartNVME).totalTimeForTemperature1,
'Total Time For Temperature 2': (disk.SmartData as SmartNVME).totalTimeForTemperature2,
'Unsafe Shutdowns': (disk.SmartData as SmartNVME).unsafeShutdowns,
'Warning Composite Temp Time': (disk.SmartData as SmartNVME).warningCompositeTempTime
};
} else if (disk.Type === 'HDD' || disk.Type === 'SSD') {
const data = disk.SmartData as SmartCtl;
const attributes: SmartAttribute[] = [];
if (disk.Type === 'NVMe') {
return {
'Available Spare': (disk.SmartData as SmartNVME).availableSpare,
'Available Spare Threshold': (disk.SmartData as SmartNVME).availableSpareThreshold,
'Controller Busy Time': (disk.SmartData as SmartNVME).controllerBusyTime,
'Critical Warning': (disk.SmartData as SmartNVME).criticalWarning,
'Critical Warning State': {
'Available Spare': (disk.SmartData as SmartNVME).criticalWarningState.availableSpare,
'Device Reliability': (disk.SmartData as SmartNVME).criticalWarningState.deviceReliability,
'Read Only': (disk.SmartData as SmartNVME).criticalWarningState.readOnly,
Temperature: (disk.SmartData as SmartNVME).criticalWarningState.temperature,
'Volatile Memory Backup': (disk.SmartData as SmartNVME).criticalWarningState
.volatileMemoryBackup
},
'Data Units Read': (disk.SmartData as SmartNVME).dataUnitsRead,
'Data Units Written': (disk.SmartData as SmartNVME).dataUnitsWritten,
'Error Info Log Entries': (disk.SmartData as SmartNVME).errorInfoLogEntries,
'Host Read Commands': (disk.SmartData as SmartNVME).hostReadCommands,
'Host Write Commands': (disk.SmartData as SmartNVME).hostWriteCommands,
'Media Errors': (disk.SmartData as SmartNVME).mediaErrors,
'Percentage Used': (disk.SmartData as SmartNVME).percentageUsed,
'Power Cycles': (disk.SmartData as SmartNVME).powerCycles,
'Power On Hours': (disk.SmartData as SmartNVME).powerOnHours,
Temperature: (disk.SmartData as SmartNVME).temperature,
'Temperature 1 Transition Count': (disk.SmartData as SmartNVME).temperature1TransitionCnt,
'Temperature 2 Transition Count': (disk.SmartData as SmartNVME).temperature2TransitionCnt,
'Total Time For Temperature 1': (disk.SmartData as SmartNVME).totalTimeForTemperature1,
'Total Time For Temperature 2': (disk.SmartData as SmartNVME).totalTimeForTemperature2,
'Unsafe Shutdowns': (disk.SmartData as SmartNVME).unsafeShutdowns,
'Warning Composite Temp Time': (disk.SmartData as SmartNVME).warningCompositeTempTime
};
} else if (disk.Type === 'HDD' || disk.Type === 'SSD') {
const data = disk.SmartData as SmartCtl;
const attributes: SmartAttribute[] = [];
if (data?.ata_smart_attributes?.table?.length) {
for (const element of data.ata_smart_attributes.table) {
attributes.push({
ID: element.id,
Name: element.name,
Value: element.value,
Worst: element.worst,
Threshold: element.thresh,
Flags: element.flags.string,
Failing: element.when_failed || '-'
});
}
}
if (data?.ata_smart_attributes?.table?.length) {
for (const element of data.ata_smart_attributes.table) {
attributes.push({
ID: element.id,
Name: element.name,
Value: element.value,
Worst: element.worst,
Threshold: element.thresh,
Flags: element.flags.string,
Failing: element.when_failed || '-'
});
}
}
if (attributes.length > 0) {
return attributes;
}
}
if (attributes.length > 0) {
return attributes;
}
}
return {};
return {};
}
export function diskSpaceAvailable(disk: Disk, required: number): boolean {
if (disk.Usage === 'Partitions') {
const total = disk.Size;
const used = disk.Partitions.reduce((acc, cur) => acc + cur.size, 0);
return total - used >= required;
}
if (disk.Usage === 'Partitions') {
const total = disk.Size;
const used = disk.Partitions.reduce((acc, cur) => acc + cur.size, 0);
return total - used >= required;
}
return disk.Size >= required;
return disk.Size >= required;
}
export function getGPTLabel(disk: Disk, pools: Zpool[]): string {
if (disk.GPT) {
return 'Yes';
}
if (disk) {
if (disk.GPT) {
return 'Yes';
}
if (disk.GPT === false) {
if (pools.length > 0) {
if (pools.some((pool) => pool.vdevs.some((vdev) => vdev.name === disk.Device))) {
return '-';
}
}
}
if (disk.GPT === false) {
if (pools.length > 0) {
if (pools.some((pool) => pool.vdevs.some((vdev) => vdev.name === disk.Device))) {
return '-';
}
}
}
}
return 'No';
return 'No';
}
+95 -95
View File
@@ -9,122 +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;
}
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;
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) {
node.classList.add(state.dragover_class);
}
}
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();
dragCounter = 0;
node.classList.remove(state.dragover_class);
dragCounter = 0;
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);
}
};
destroy() {
node.removeEventListener('dragenter', handle_dragenter);
node.removeEventListener('dragleave', handle_dragleave);
node.removeEventListener('dragover', handle_dragover);
node.removeEventListener('drop', handle_drop);
}
};
}
+267 -216
View File
@@ -10,6 +10,7 @@
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
import * as Select from '$lib/components/ui/select/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { Disk } from '$lib/types/disk/disk';
import type { Zpool } from '$lib/types/zfs/pool';
import { simplifyDisks } from '$lib/utils/disk';
@@ -68,6 +69,8 @@
let pools = $results[1].data as Zpool[];
let advancedChecked: boolean = $state(false);
$inspect('advancedChecked', advancedChecked);
let useableDisks = $derived.by(() => {
const unusedDisks: UnusedDisk[] = [];
for (const disk of disks) {
@@ -76,7 +79,7 @@
name: disk.Device,
size: disk.Size,
gpt: disk.GPT,
type: disk.Type
type: disk.Type === 'Unknown' ? 'HDD' : disk.Type
});
}
@@ -254,9 +257,9 @@
<Dialog.Root bind:open onOutsideClick={() => close()}>
<Dialog.Content
class="fixed left-1/2 top-1/2 max-h-[90vh] w-[80%] -translate-x-1/2 -translate-y-1/2 transform gap-0 overflow-y-auto p-0 transition-all duration-300 ease-in-out lg:max-w-[70%]"
class="fixed left-1/2 top-1/2 max-h-[90vh] w-[80%] -translate-x-1/2 -translate-y-1/2 transform gap-0 overflow-visible overflow-y-auto p-0 transition-all duration-300 ease-in-out lg:max-w-[70%]"
>
<div class="flex items-center justify-between p-4">
<div class="flex items-center justify-between px-4 py-3">
<Dialog.Header class="p-0">
<Dialog.Title>Create ZFS Pool</Dialog.Title>
</Dialog.Header>
@@ -269,13 +272,13 @@
</Dialog.Close>
</div>
<Tabs.Root value="tab-1" class="w-full overflow-hidden">
<Tabs.List class="grid w-full grid-cols-2">
<Tabs.Trigger value="tab-1">Devices</Tabs.Trigger>
<Tabs.Trigger value="tab-2">Options</Tabs.Trigger>
<Tabs.List class="grid w-full grid-cols-2 p-0 px-4">
<Tabs.Trigger value="tab-1" class="border-b">Devices</Tabs.Trigger>
<Tabs.Trigger value="tab-2" class="border-b">Options</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="tab-1">
<Card.Root class="pb-6">
<Card.Content class="flex gap-6 !pb-0">
<Tabs.Content class="mt-0" value="tab-1">
<Card.Root class="border-none pb-4">
<Card.Content class="flex gap-4 p-4 !pb-0">
<div class="flex-1 space-y-1">
<Label for="name">Name</Label>
<Input type="text" id="name" placeholder="name" bind:value={name} />
@@ -285,33 +288,60 @@
<Input type="number" id="vdev_count" placeholder="1" min={1} bind:value={vdevCount} />
</div>
</Card.Content>
<Card.Content class="flex flex-col gap-6 !pb-0">
<Label for="vdev_count" class="">VDEV</Label>
<div
class="border-primary-foreground bg-primary-foreground w-full overflow-hidden rounded-lg border-y p-4"
>
<ScrollArea class="w-full whitespace-nowrap rounded-md" orientation="horizontal">
<div class="flex justify-center gap-7 pr-4">
{#each Array(vdevCount) as _, i}
{#if diskContainers}
{#if diskContainers[i]}
<div class="flex flex-col">
<div
class="h-28 w-48 flex-shrink-0 overflow-auto rounded-lg border border-neutral-300 bg-neutral-200 p-2 dark:border-neutral-800 dark:bg-neutral-950"
use:dropzone={{
on_dropzone: (_: unknown, event: DragEvent) =>
handleDiskDrop(i, event)
}}
>
{#if diskContainers[i].length === 0}
<div class="flex h-full items-center justify-center text-neutral-500">
Drop disks here
<Card.Content class="flex flex-col gap-4 p-4 !pb-0">
<div>
<Label for="vdev_count" class="">VDEV</Label>
<div
class="border-primary-foreground bg-primary-foreground mt-1 w-full overflow-hidden rounded-lg border-y p-4"
>
<ScrollArea class="w-full whitespace-nowrap rounded-md" orientation="horizontal">
<div class="flex items-center justify-center gap-7 pr-4">
{#each Array(vdevCount) as _, i}
{#if diskContainers}
{#if diskContainers[i]}
<div class="relative flex flex-col">
{#if diskContainers[i].length > 0}
<div
class="absolute right-1 top-1 z-50 cursor-pointer text-yellow-700 hover:text-yellow-600"
>
<Tooltip.Root>
<Tooltip.Trigger
><Icon
icon="carbon:warning-filled"
class="h-5 w-5"
/></Tooltip.Trigger
>
<Tooltip.Content>
<p>
Lorem Ipsum is simply dummy text of the printing and
typesetting industry. Lorem Ipsum has been the industry's
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
{:else}
<div class="flex flex-wrap items-center justify-center gap-2">
{#each diskContainers[i] as disk (disk.id)}
<div animate:flip={{ duration: 300 }} class="relative">
<!-- {#if disk.type === 'NVMe'}
{/if}
<div
class={`relative h-28 w-48 flex-shrink-0 overflow-auto rounded-lg bg-neutral-200 p-2 dark:bg-neutral-950
${diskContainers[i].length > 0 ? 'border border-yellow-700 ' : ''}`}
use:dropzone={{
on_dropzone: (_: unknown, event: DragEvent) =>
handleDiskDrop(i, event),
dragover_class: 'droppable'
}}
>
{#if diskContainers[i].length === 0}
<div
class="flex h-full items-center justify-center text-neutral-500"
>
Drop disks here
</div>
{:else}
<div
class="flex h-full flex-wrap items-center justify-center gap-2"
>
{#each diskContainers[i] as disk (disk.id)}
<div animate:flip={{ duration: 300 }} class="relative">
<!-- {#if disk.type === 'NVMe'}
<Icon
icon="bi:nvme"
class="h-11 w-11 rotate-90 text-blue-500"
@@ -326,183 +356,197 @@
: 'text-green-500'}"
/>
{/if} -->
{#if disk.type === 'SSD'}
<Icon
icon="icon-park-outline:ssd"
class="h-11 w-11 text-blue-500"
/>
{:else if disk.type === 'NVMe'}
<Icon
icon="bi:nvme"
class="h-11 w-11 rotate-90 text-blue-500"
/>
{:else if disk.type === 'HDD'}
<Icon icon="mdi:harddisk" class="h-11 w-11 text-green-500" />
{:else if disk.type === 'Partition'}
<Icon
icon="ant-design:partition-outlined"
class="h-11 w-11 rotate-90 text-blue-500"
/>
{/if}
<div class="max-w-[48px] truncate text-center text-xs">
{disk.name.split('/').pop()}
{#if disk.type === 'SSD'}
<Icon
icon="icon-park-outline:ssd"
class="h-11 w-11 text-blue-500"
/>
{:else if disk.type === 'NVMe'}
<Icon
icon="bi:nvme"
class="h-11 w-11 rotate-90 text-blue-500"
/>
{:else if disk.type === 'HDD'}
<Icon
icon="mdi:harddisk"
class="h-11 w-11 text-green-500"
/>
{:else if disk.type === 'Partition'}
<Icon
icon="ant-design:partition-outlined"
class="h-11 w-11 rotate-90 text-blue-500"
/>
{/if}
<div class="max-w-[48px] truncate text-center text-xs">
{disk.name.split('/').pop()}
</div>
<button
class="absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white hover:bg-red-600"
onclick={() => returnDiskToPool(i, disk.id)}
>
<Icon icon="mdi:close" class="h-3 w-3" />
</button>
</div>
<button
class="absolute -right-1 -top-1 rounded-full bg-red-500 p-0.5 text-white hover:bg-red-600"
onclick={() => returnDiskToPool(i, disk.id)}
>
<Icon icon="mdi:close" class="h-3 w-3" />
</button>
</div>
{/each}
</div>
{/if}
{/each}
</div>
{/if}
</div>
<p
class="mt-2 text-center text-xs text-neutral-800 dark:text-neutral-300"
>
VDEV {i + 1}
</p>
</div>
<p
class="mt-2 text-center text-xs text-neutral-800 dark:text-neutral-300"
>
VDEV {i + 1}
</p>
</div>
{/if}
{/if}
{/if}
{/each}
</div>
</ScrollArea>
{/each}
</div>
</ScrollArea>
</div>
</div>
<Label for="vdev_count" class="">Disks</Label>
<div
class="border-primary-foreground bg-primary-foreground grid grid-cols-4 gap-6 overflow-hidden border-y p-4"
>
<div class="">
<label class="">HDD</label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="mt-1 w-full whitespace-nowrap rounded-md"
orientation="horizontal"
>
<div class="flex min-h-[80px] justify-center gap-4 pr-4">
{#each availableDisks.filter((disk) => disk.type === 'HDD') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="mdi:harddisk" class="h-11 w-11 text-green-500" />
<div>
<Label for="vdev_count" class="">Disks</Label>
<div
class="border-primary-foreground bg-primary-foreground mt-1 grid grid-cols-4 gap-6 overflow-hidden border-y p-4"
>
<div class="">
<Label class="">HDD</Label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="w-full whitespace-nowrap rounded-md"
orientation="horizontal"
>
<div class="flex min-h-[80px] items-center justify-center gap-4">
{#each availableDisks.filter((disk) => disk.type === 'HDD') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="mdi:harddisk" class="h-11 w-11 text-green-500" />
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
{/each}
{/each}
{#if availableDisks.filter((disk) => disk.type === 'HDD').length === 0}
<div class="flex h-16 w-full items-center justify-center text-neutral-400">
No available disks
</div>
{/if}
</div>
</ScrollArea>
{#if availableDisks.filter((disk) => disk.type === 'HDD').length === 0}
<div
class="flex h-16 w-full items-center justify-center text-neutral-400"
>
No available disks
</div>
{/if}
</div>
</ScrollArea>
</div>
</div>
</div>
<div class="">
<label class="">SSD</label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="mt-1 w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] justify-center gap-4 pr-4">
{#each availableDisks.filter((disk) => disk.type === 'SSD') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="icon-park-outline:ssd" class="h-11 w-11 text-blue-500" />
<div class="">
<Label class="">SSD</Label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] items-center justify-center gap-4">
{#each availableDisks.filter((disk) => disk.type === 'SSD') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="icon-park-outline:ssd" class="h-11 w-11 text-blue-500" />
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
{/each}
{/each}
{#if availableDisks.filter((disk) => disk.type === 'SSD').length === 0}
<div class="flex h-16 w-full items-center justify-center text-neutral-400">
No available disks
</div>
{/if}
</div>
</ScrollArea>
{#if availableDisks.filter((disk) => disk.type === 'SSD').length === 0}
<div
class="flex h-16 w-full items-center justify-center text-neutral-400"
>
No available disks
</div>
{/if}
</div>
</ScrollArea>
</div>
</div>
</div>
<div class="">
<label class="">NVME</label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="mt-1 w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] justify-center gap-4 pr-4">
{#each availableDisks.filter((disk) => disk.type === 'NVMe') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="bi:nvme" class="h-11 w-11 rotate-90 text-blue-500" />
<div class="">
<Label class="">NVME</Label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] items-center justify-center gap-4">
{#each availableDisks.filter((disk) => disk.type === 'NVMe') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon icon="bi:nvme" class="h-11 w-11 rotate-90 text-blue-500" />
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
{/each}
{/each}
{#if availableDisks.filter((disk) => disk.type === 'NVMe').length === 0}
<div class="flex h-16 w-full items-center justify-center text-neutral-400">
No available disks
</div>
{/if}
</div>
</ScrollArea>
{#if availableDisks.filter((disk) => disk.type === 'NVMe').length === 0}
<div
class="flex h-16 w-full items-center justify-center text-neutral-400"
>
No available disks
</div>
{/if}
</div>
</ScrollArea>
</div>
</div>
</div>
<div class="">
<label class="">Partition</label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="mt-1 w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] justify-center gap-4 pr-4">
{#each availableDisks.filter((disk) => disk.type === 'Partition') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon
icon="ant-design:partition-outlined"
class="h-11 w-11 rotate-90 text-blue-500"
/>
<div class="">
<Label class="">Partition</Label>
<div class="mt-1 rounded-lg bg-neutral-200 p-4 dark:bg-neutral-950">
<ScrollArea
class="w-full whitespace-nowrap rounded-md "
orientation="horizontal"
>
<div class="flex min-h-[80px] items-center justify-center gap-4">
{#each availableDisks.filter((disk) => disk.type === 'Partition') as disk (disk.id)}
<div class="text-center" animate:flip={{ duration: 300 }}>
<div class="cursor-move" use:draggable={disk.id}>
<Icon
icon="ant-design:partition-outlined"
class="h-11 w-11 rotate-90 text-blue-500"
/>
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
<div class="max-w-[64px] truncate text-xs">
{disk.name.split('/').pop()}
</div>
<div class="text-xs text-neutral-400">
{Math.round(disk.size / (1024 * 1024 * 1024))} GB
</div>
</div>
{/each}
{/each}
{#if availableDisks.filter((disk) => disk.type === 'Partition').length === 0}
<div class="flex h-16 w-full items-center justify-center text-neutral-400">
No available partitions
</div>
{/if}
</div>
</ScrollArea>
{#if availableDisks.filter((disk) => disk.type === 'Partition').length === 0}
<div
class="flex h-16 w-full items-center justify-center text-neutral-400"
>
No available partitions
</div>
{/if}
</div>
</ScrollArea>
</div>
</div>
</div>
</div>
@@ -510,17 +554,17 @@
</Card.Root>
</Tabs.Content>
<Tabs.Content value="tab-2">
<Card.Root>
<Card.Content>
<Tabs.Content class="mt-0" value="tab-2">
<Card.Root class="min-h-[20vh] border-none pb-6">
<Card.Content class="flex flex-col gap-4 p-4 !pb-0">
<div transition:slide class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<Label class="w-24 whitespace-nowrap text-sm" for="terms">RAID:</Label>
<Select.Root portal={null}>
<div class="h-full space-y-1">
<Label class="w-24 whitespace-nowrap text-sm" for="raid">RAID:</Label>
<Select.Root>
<Select.Trigger class="w-full">
<Select.Value placeholder="Select a RAID" />
</Select.Trigger>
<Select.Content>
<Select.Content class="max-h-36 overflow-y-auto">
<Select.Group>
{#each raid as fruit}
<Select.Item value={fruit.value} label={fruit.label}
@@ -529,12 +573,13 @@
{/each}
</Select.Group>
</Select.Content>
<Select.Input name="favoriteFruit" />
<Select.Input name="raid" />
</Select.Root>
</div>
<div>
<Label class="w-24 whitespace-nowrap text-sm" for="terms">Compression:</Label>
<Select.Root portal={null}>
<div class="space-y-1">
<Label class="w-24 whitespace-nowrap text-sm" for="compression">Compression:</Label>
<Select.Root>
<Select.Trigger class="w-full">
<Select.Value placeholder="Select a Compression" />
</Select.Trigger>
@@ -547,12 +592,13 @@
{/each}
</Select.Group>
</Select.Content>
<Select.Input name="favoriteFruit" />
<Select.Input name="compression" />
</Select.Root>
</div>
<div>
<Label class="w-24 whitespace-nowrap text-sm" for="terms">ASHIFT:</Label>
<Select.Root portal={null}>
<div class="space-y-1">
<Label class="w-24 whitespace-nowrap text-sm" for="ashift">ASHIFT:</Label>
<Select.Root>
<Select.Trigger class="w-full">
<Select.Value placeholder="Select a ASHIFT" />
</Select.Trigger>
@@ -565,19 +611,24 @@
{/each}
</Select.Group>
</Select.Content>
<Select.Input name="favoriteFruit" />
<Select.Input name="ashift" />
</Select.Root>
</div>
</div>
<div transition:slide class="mt-2 flex items-center space-x-2 md:mt-0">
<div transition:slide class="mt-2 flex items-center space-x-2 md:mt-3">
<Label
id="terms-label"
for="terms"
id="advanced-label"
for="advanced-checkbox"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Advanced
</Label>
<Checkbox id="terms" bind:checked={advancedChecked} aria-labelledby="terms-label" />
<Checkbox
id="advanced-checkbox"
bind:checked={advancedChecked}
aria-labelledby="advanced-label"
/>
</div>
{#if advancedChecked}
@@ -605,14 +656,14 @@
onclick={() => removePair(index)}
class="hover:bg-muted rounded px-2 py-1 text-white"
>
<Icon icon="ic:twotone-remove" class="h-5 w-5" />
<Icon icon="ic:twotone-remove" class="h-6 w-6" />
</button>
{/if}
</div>
{/each}
</div>
<div transition:slide class="flex justify-end">
<button onclick={addPair} class=" hover:bg-muted rounded px-3 py-1 text-white">
<button onclick={addPair} class=" hover:bg-muted rounded px-2 py-1 text-white">
<Icon icon="icons8:plus" class="h-6 w-6" />
</button>
</div>
@@ -622,7 +673,7 @@
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer class="flex justify-between gap-2 border-t px-6 py-4">
<Dialog.Footer class="flex justify-between gap-2 border-t px-4 py-3">
<div class="flex gap-2">
<Button variant="outline" class="h-8 disabled:!pointer-events-auto" onclick={() => close()}
>Cancel</Button