diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 0b0c497690..2c3f0be77e 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -3580,4 +3580,4 @@ export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"Op export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}}]}},{"kind":"Field","name":{"kind":"Name","value":"packages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; -export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts index 75b06cad10..a0defbd94e 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts @@ -19,6 +19,7 @@ type MockInternalBootSelection = { }; type InternalBootVm = { + poolMode: 'dedicated' | 'hybrid'; getDeviceSelectItems: (index: number) => Array<{ value: string; label: string; disabled?: boolean }>; }; @@ -100,7 +101,7 @@ vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ useOnboardingDraftStore: () => draftStore, })); -const gib = (value: number) => value * 1024 * 1024 * 1024; +const gb = (value: number) => value * 1000 * 1000 * 1000; const buildContext = ( overrides: Partial = {} @@ -211,42 +212,42 @@ describe('OnboardingInternalBootStep', () => { { id: 'BOOT-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'BOOT-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'PARITY-1', device: '/dev/sdb', - size: gib(32), + size: gb(32), serialNum: 'PARITY-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'ARRAY-1', device: '/dev/sdc', - size: gib(32), + size: gb(32), serialNum: 'ARRAY-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'CACHE-1', device: '/dev/sdd', - size: gib(32), + size: gb(32), serialNum: 'CACHE-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'SMALL-1', device: '/dev/sde', - size: gib(6), + size: gb(6), serialNum: 'SMALL-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'USB-1', device: '/dev/sdf', - size: gib(32), + size: gb(32), serialNum: 'USB-1', interfaceType: DiskInterfaceType.USB, }, @@ -288,7 +289,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'WD-TEST-1234', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'WD-TEST-1234', interfaceType: DiskInterfaceType.SATA, }, @@ -303,7 +304,7 @@ describe('OnboardingInternalBootStep', () => { expect.arrayContaining([ expect.objectContaining({ value: 'WD-TEST-1234', - label: 'WD-TEST-1234 - 34.4 GB (sda)', + label: 'WD-TEST-1234 - 32.0 GB (sda)', }), ]) ); @@ -324,7 +325,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'ELIGIBLE-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'ELIGIBLE-1', interfaceType: DiskInterfaceType.SATA, }, @@ -357,7 +358,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'ELIGIBLE-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'ELIGIBLE-1', interfaceType: DiskInterfaceType.SATA, }, @@ -402,7 +403,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'ELIGIBLE-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'ELIGIBLE-1', interfaceType: DiskInterfaceType.SATA, }, @@ -433,21 +434,21 @@ describe('OnboardingInternalBootStep', () => { { id: 'SMALL-1', device: '/dev/sdb', - size: gib(6), + size: gb(6), serialNum: 'SMALL-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'ELIGIBLE-1', device: '/dev/sdc', - size: gib(32), + size: gb(32), serialNum: 'ELIGIBLE-1', interfaceType: DiskInterfaceType.SATA, }, { id: 'USB-1', device: '/dev/sdd', - size: gib(32), + size: gb(32), serialNum: 'USB-1', interfaceType: DiskInterfaceType.USB, }, @@ -481,6 +482,47 @@ describe('OnboardingInternalBootStep', () => { expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined(); }); + it('allows marketed 8 GB devices in dedicated mode but not hybrid mode', async () => { + draftStore.bootMode = 'storage'; + contextResult.value = buildContext({ + assignableDisks: [ + { + id: 'DEDICATED-8GB', + device: '/dev/sda', + size: 8_000_000_000, + serialNum: 'DEDICATED-8GB', + interfaceType: DiskInterfaceType.SATA, + }, + { + id: 'LARGER-DRIVE', + device: '/dev/sdb', + size: gb(32), + serialNum: 'LARGER-DRIVE', + interfaceType: DiskInterfaceType.SATA, + }, + ], + }); + + const wrapper = mountComponent(); + await flushPromises(); + + const vm = wrapper.vm as unknown as InternalBootVm; + expect(vm.poolMode).toBe('dedicated'); + expect(vm.getDeviceSelectItems(0)).toEqual( + expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-8GB' })]) + ); + vm.poolMode = 'hybrid'; + await flushPromises(); + + expect(vm.getDeviceSelectItems(0)).not.toEqual( + expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-8GB' })]) + ); + await wrapper.get('[data-testid="internal-boot-eligibility-toggle"]').trigger('click'); + await flushPromises(); + expect(wrapper.text()).toContain('DEDICATED-8GB - 8.0 GB (sda)'); + expect(wrapper.text()).toContain('TOO_SMALL'); + }); + it('treats disks present in devs.ini as assignable', async () => { draftStore.bootMode = 'storage'; contextResult.value = buildContext({ @@ -488,7 +530,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'UNASSIGNED-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'UNASSIGNED-1', interfaceType: DiskInterfaceType.SATA, }, @@ -516,7 +558,7 @@ describe('OnboardingInternalBootStep', () => { { id: 'UNASSIGNED-1', device: '/dev/sda', - size: gib(32), + size: gb(32), serialNum: 'UNASSIGNED-1', interfaceType: DiskInterfaceType.SATA, }, diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index 1cb8b99d9a..581523d2a9 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -430,14 +430,14 @@ describe('OnboardingSummaryStep', () => { { id: 'DISK-A', device: '/dev/sda', - size: 500 * 1024 * 1024 * 1024, + size: 500 * 1000 * 1000 * 1000, serialNum: 'DISK-A', interfaceType: DiskInterfaceType.SATA, }, { id: 'DISK-B', device: '/dev/sdb', - size: 250 * 1024 * 1024 * 1024, + size: 250 * 1000 * 1000 * 1000, serialNum: 'DISK-B', interfaceType: DiskInterfaceType.SATA, }, @@ -1181,8 +1181,8 @@ describe('OnboardingSummaryStep', () => { const { wrapper } = mountComponent(); - expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)'); - expect(wrapper.text()).toContain('DISK-B - 268 GB (sdb)'); + expect(wrapper.text()).toContain('DISK-A - 500 GB (sda)'); + expect(wrapper.text()).toContain('DISK-B - 250 GB (sdb)'); }); it('requires confirmation before applying storage boot drive changes', async () => { diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue index 2c8e9cc28d..ae7ef9a51c 100644 --- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue @@ -38,8 +38,8 @@ interface InternalBootDeviceOption { value: string; label: string; device: string; + sizeBytes: number | null; sizeMiB: number | null; - ineligibilityCodes: InternalBootDiskEligibilityCode[]; warningCodes: InternalBootDiskWarningCode[]; } @@ -74,7 +74,8 @@ type InternalBootDiskWarningCode = 'HAS_INTERNAL_BOOT_PARTITIONS'; type InternalBootDiskIssueCode = InternalBootDiskEligibilityCode | InternalBootDiskWarningCode; const MIN_BOOT_SIZE_MIB = 4096; -const MIN_ELIGIBLE_DEVICE_SIZE_MIB = MIN_BOOT_SIZE_MIB * 2; +const MIN_DEDICATED_DEVICE_SIZE_BYTES = 8 * 1000 * 1000 * 1000; +const MIN_HYBRID_DEVICE_SIZE_MIB = MIN_BOOT_SIZE_MIB * 2; const DEFAULT_BOOT_SIZE_MIB = 16384; const BOOT_SIZE_PRESETS_MIB = [16384, 32768, 65536, 131072]; const SYSTEM_ELIGIBILITY_MESSAGE_KEYS: Record = { @@ -157,6 +158,18 @@ const updateBios = ref(true); const internalBootContext = computed(() => contextResult.value?.internalBootContext ?? null); +const getIneligibilityCodes = ( + option: Pick +): InternalBootDiskEligibilityCode[] => { + if (poolMode.value === 'dedicated') { + return option.sizeBytes !== null && option.sizeBytes < MIN_DEDICATED_DEVICE_SIZE_BYTES + ? ['TOO_SMALL'] + : []; + } + + return option.sizeMiB !== null && option.sizeMiB < MIN_HYBRID_DEVICE_SIZE_MIB ? ['TOO_SMALL'] : []; +}; + const templateData = computed(() => { const data: GetInternalBootContextQuery['internalBootContext'] | null = internalBootContext.value; if (!data) { @@ -168,13 +181,8 @@ const templateData = computed(() => { const device = normalizeDeviceName(disk.device); const sizeBytes = disk.size; const sizeMiB = toSizeMiB(sizeBytes); - const ineligibilityCodes: InternalBootDiskEligibilityCode[] = []; const warningCodes: InternalBootDiskWarningCode[] = []; - if (sizeMiB !== null && sizeMiB < MIN_ELIGIBLE_DEVICE_SIZE_MIB) { - ineligibilityCodes.push('TOO_SMALL'); - } - const serialNum = disk.serialNum?.trim() || ''; const diskId = disk.id?.trim() || ''; const optionValue = serialNum || diskId || device; @@ -190,8 +198,8 @@ const templateData = computed(() => { value: optionValue, label: buildDeviceLabel(displayId, sizeLabel, device), device, + sizeBytes: Number.isFinite(sizeBytes) && sizeBytes > 0 ? sizeBytes : null, sizeMiB, - ineligibilityCodes, warningCodes, }; }) @@ -264,7 +272,7 @@ const bootEligibilityState = computed(() => { }); const allDeviceOptions = computed(() => templateData.value?.deviceOptions ?? []); const deviceOptions = computed(() => - allDeviceOptions.value.filter((option) => option.ineligibilityCodes.length === 0) + allDeviceOptions.value.filter((option) => getIneligibilityCodes(option).length === 0) ); const slotOptions = computed(() => templateData.value?.slotOptions ?? [1, 2]); const reservedNames = computed(() => new Set(templateData.value?.reservedNames ?? [])); @@ -293,10 +301,14 @@ const systemEligibilityCodes = computed(() }); const diskEligibilityIssues = computed(() => allDeviceOptions.value - .filter((option) => option.ineligibilityCodes.length > 0) .map((option) => ({ label: option.label, - codes: [...option.ineligibilityCodes], + codes: getIneligibilityCodes(option), + })) + .filter((option) => option.codes.length > 0) + .map((option) => ({ + label: option.label, + codes: [...option.codes], })) ); const selectedDriveWarnings = computed(() => diff --git a/web/src/components/Onboarding/store/onboardingDraft.ts b/web/src/components/Onboarding/store/onboardingDraft.ts index 10a17a45cb..242807cfc3 100644 --- a/web/src/components/Onboarding/store/onboardingDraft.ts +++ b/web/src/components/Onboarding/store/onboardingDraft.ts @@ -64,6 +64,8 @@ const normalizePersistedPoolMode = (value: unknown): OnboardingPoolMode => { return 'hybrid'; }; +const DEFAULT_BOOT_SIZE_MIB = 16384; + const normalizePersistedInternalBootSelection = ( value: unknown ): OnboardingInternalBootSelection | null => { @@ -87,13 +89,13 @@ const normalizePersistedInternalBootSelection = ( const devices = Array.isArray(candidate.devices) ? candidate.devices.filter((item): item is string => typeof item === 'string') : []; - const parsedBootSize = Number(candidate.bootSizeMiB); + const parsedBootSizeMiB = Number(candidate.bootSizeMiB); const bootSizeMiB = poolMode === 'dedicated' ? 0 - : Number.isFinite(parsedBootSize) && parsedBootSize > 0 - ? parsedBootSize - : 16384; + : Number.isFinite(parsedBootSizeMiB) && parsedBootSizeMiB > 0 + ? parsedBootSizeMiB + : DEFAULT_BOOT_SIZE_MIB; return { poolName,