diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index cf57e381c2..a17ae1ee06 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -338,6 +338,11 @@ type Disk implements Node { """The serial number of the disk""" serialNum: String! + """ + Device identifier from emhttp devs.ini used by disk assignment commands + """ + emhttpDeviceId: String + """The interface type of the disk""" interfaceType: DiskInterfaceType! @@ -580,6 +585,9 @@ type Vars implements Node { cacheNumDevices: Int cacheSbNumDisks: Int fsState: String + bootEligible: Boolean + enableBootTransfer: String + reservedNames: String """Human friendly string of array events happening""" fsProgress: String @@ -1050,6 +1058,13 @@ type Customization { availableLanguages: [Language!] } +"""Result of attempting internal boot pool setup""" +type OnboardingInternalBootResult { + ok: Boolean! + code: Int + output: String! +} + type RCloneDrive { """Provider name""" name: String! @@ -1361,6 +1376,9 @@ type OnboardingMutations { """Clear onboarding override state and reload from disk""" clearOnboardingOverride: Onboarding! + + """Create and configure internal boot pool via emcmd operations""" + createInternalBootPool(input: CreateInternalBootPoolInput!): OnboardingInternalBootResult! } """Onboarding override input for testing""" @@ -1435,6 +1453,15 @@ input PartnerInfoOverrideInput { branding: BrandingConfigInput } +"""Input for creating an internal boot pool during onboarding""" +input CreateInternalBootPoolInput { + poolName: String! + devices: [String!]! + bootSizeMiB: Int! + updateBios: Boolean! + reboot: Boolean +} + """Unraid plugin management mutations""" type UnraidPluginsMutations { """Install an Unraid plugin and track installation progress""" @@ -3526,4 +3553,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} \ No newline at end of file +} diff --git a/api/src/__test__/store/modules/emhttp.test.ts b/api/src/__test__/store/modules/emhttp.test.ts index cfea38b26f..e9e987e859 100644 --- a/api/src/__test__/store/modules/emhttp.test.ts +++ b/api/src/__test__/store/modules/emhttp.test.ts @@ -1001,6 +1001,7 @@ test('After init returns values from cfg file for all fields', { timeout: 30000 expect(varState).toMatchInlineSnapshot(` { "bindMgt": false, + "bootEligible": false, "cacheNumDevices": NaN, "cacheSbNumDisks": NaN, "comment": "Dev Server", diff --git a/api/src/__test__/store/state-parsers/var.test.ts b/api/src/__test__/store/state-parsers/var.test.ts index 1fb4700dd7..9b70869387 100644 --- a/api/src/__test__/store/state-parsers/var.test.ts +++ b/api/src/__test__/store/state-parsers/var.test.ts @@ -18,6 +18,7 @@ test('Returns parsed state file', async () => { expect(parse(stateFile)).toMatchInlineSnapshot(` { "bindMgt": false, + "bootEligible": false, "cacheNumDevices": NaN, "cacheSbNumDisks": NaN, "comment": "Dev Server", diff --git a/api/src/core/types/states/var.ts b/api/src/core/types/states/var.ts index a994dcc4c0..2245f78c34 100644 --- a/api/src/core/types/states/var.ts +++ b/api/src/core/types/states/var.ts @@ -39,6 +39,12 @@ export type Var = { fsProgress: string; /** Current state of the array. */ fsState: string; + /** Whether this system is eligible to create a bootable pool. */ + bootEligible: boolean; + /** Whether boot transfer from flash to internal is enabled ("yes" means currently booted from flash). */ + enableBootTransfer?: string; + /** Comma-separated list of reserved names from var.ini. */ + reservedNames?: string; fsUnmountableMask: string; fuseDirectio: string; fuseDirectioDefault: string; diff --git a/api/src/store/state-parsers/var.ts b/api/src/store/state-parsers/var.ts index d3b9b888f9..140569d71e 100644 --- a/api/src/store/state-parsers/var.ts +++ b/api/src/store/state-parsers/var.ts @@ -44,6 +44,9 @@ export type VarIni = { fsNumUnmountable: string; fsProgress: string; fsState: string; + bootEligible: string; + enableBootTransfer?: string; + reservedNames?: string; fsUnmountableMask: string; fuseDirectio: string; fuseDirectioDefault: string; @@ -195,6 +198,7 @@ export const parse: StateFileToIniParserMap['var'] = (iniFile) => { fsCopyPrcnt: toNumber(iniFile.fsCopyPrcnt), fsNumMounted: toNumber(iniFile.fsNumMounted), fsNumUnmountable: toNumber(iniFile.fsNumUnmountable), + bootEligible: iniBooleanToJsBoolean(iniFile.bootEligible, false), hideDotFiles: iniBooleanToJsBoolean(iniFile.hideDotFiles, false), localMaster: iniBooleanToJsBoolean(iniFile.localMaster, false), maxArraysz: toNumber(iniFile.maxArraysz), diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 73d88de348..b299c1b8d5 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -652,6 +652,15 @@ export type CreateApiKeyInput = { roles?: InputMaybe>; }; +/** Input for creating an internal boot pool during onboarding */ +export type CreateInternalBootPoolInput = { + bootSizeMiB: Scalars['Int']['input']; + devices: Array; + poolName: Scalars['String']['input']; + reboot?: InputMaybe; + updateBios: Scalars['Boolean']['input']; +}; + export type CreateRCloneRemoteInput = { name: Scalars['String']['input']; parameters: Scalars['JSON']['input']; @@ -701,6 +710,8 @@ export type Disk = Node & { bytesPerSector: Scalars['Float']['output']; /** The device path of the disk (e.g. /dev/sdb) */ device: Scalars['String']['output']; + /** Device identifier from emhttp devs.ini used by disk assignment commands */ + emhttpDeviceId?: Maybe; /** The firmware revision of the disk */ firmwareRevision: Scalars['String']['output']; id: Scalars['PrefixedID']['output']; @@ -1963,6 +1974,14 @@ export type Onboarding = { status: OnboardingStatus; }; +/** Result of attempting internal boot pool setup */ +export type OnboardingInternalBootResult = { + __typename?: 'OnboardingInternalBootResult'; + code?: Maybe; + ok: Scalars['Boolean']['output']; + output: Scalars['String']['output']; +}; + /** Onboarding related mutations */ export type OnboardingMutations = { __typename?: 'OnboardingMutations'; @@ -1970,6 +1989,8 @@ export type OnboardingMutations = { clearOnboardingOverride: Onboarding; /** Mark onboarding as completed */ completeOnboarding: Onboarding; + /** Create and configure internal boot pool via emcmd operations */ + createInternalBootPool: OnboardingInternalBootResult; /** Reset onboarding progress (for testing) */ resetOnboarding: Onboarding; /** Override onboarding state for testing (in-memory only) */ @@ -1977,6 +1998,12 @@ export type OnboardingMutations = { }; +/** Onboarding related mutations */ +export type OnboardingMutationsCreateInternalBootPoolArgs = { + input: CreateInternalBootPoolInput; +}; + + /** Onboarding related mutations */ export type OnboardingMutationsSetOnboardingOverrideArgs = { input: OnboardingOverrideInput; @@ -3175,6 +3202,7 @@ export type UserAccount = Node & { export type Vars = Node & { __typename?: 'Vars'; bindMgt?: Maybe; + bootEligible?: Maybe; cacheNumDevices?: Maybe; cacheSbNumDisks?: Maybe; comment?: Maybe; @@ -3187,6 +3215,7 @@ export type Vars = Node & { domain?: Maybe; domainLogin?: Maybe; domainShort?: Maybe; + enableBootTransfer?: Maybe; enableFruit?: Maybe; flashGuid?: Maybe; flashProduct?: Maybe; @@ -3274,6 +3303,7 @@ export type Vars = Node & { /** Registration owner */ regTo?: Maybe; regTy?: Maybe; + reservedNames?: Maybe; safeMode?: Maybe; sbClean?: Maybe; sbEvents?: Maybe; diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.model.ts b/api/src/unraid-api/graph/resolvers/disks/disks.model.ts index 0a7b276754..ee30bce0e6 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.model.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.model.ts @@ -123,6 +123,14 @@ export class Disk extends Node { @IsString() serialNum!: string; + @Field(() => String, { + nullable: true, + description: 'Device identifier from emhttp devs.ini used by disk assignment commands', + }) + @IsOptional() + @IsString() + emhttpDeviceId?: string; + @Field(() => DiskInterfaceType, { description: 'The interface type of the disk' }) @IsEnum(DiskInterfaceType) interfaceType!: DiskInterfaceType; diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts index f1ee5109c9..1668933fc5 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.spec.ts @@ -122,6 +122,27 @@ describe('DisksService', () => { }, ]; + const mockEmhttpDevices = [ + { + id: 'S4ENNF0N123456', + device: 'sda', + sectors: 1000215216, + sector_size: 512, + }, + { + id: 'WD-WCC7K7YL9876', + device: 'sdb', + sectors: 7814037168, + sector_size: 512, + }, + { + id: 'WD-SPUNDOWN123', + device: 'sdd', + sectors: 7814037168, + sector_size: 512, + }, + ]; + const mockDiskLayoutData: Systeminformation.DiskLayoutData[] = [ { device: '/dev/sda', @@ -309,6 +330,9 @@ describe('DisksService', () => { if (key === 'store.emhttp.disks') { return mockArrayDisks; } + if (key === 'store.emhttp.devices') { + return mockEmhttpDevices; + } return defaultValue; }), }; @@ -353,6 +377,7 @@ describe('DisksService', () => { expect(mockDiskLayout).toHaveBeenCalledTimes(1); expect(mockBlockDevices).toHaveBeenCalledTimes(1); expect(configService.get).toHaveBeenCalledWith('store.emhttp.disks', []); + expect(configService.get).toHaveBeenCalledWith('store.emhttp.devices', []); expect(mockBatchProcess).toHaveBeenCalledTimes(1); expect(disks).toHaveLength(mockDiskLayoutData.length); @@ -370,6 +395,7 @@ describe('DisksService', () => { expect(spinningDisk).toBeDefined(); expect(spinningDisk?.isSpinning).toBe(true); // From state expect(spinningDisk?.interfaceType).toBe(DiskInterfaceType.SATA); + expect(spinningDisk?.emhttpDeviceId).toBe('WD-WCC7K7YL9876'); // Check spun down disk const spunDownDisk = disks.find((d) => d.id === 'WD-SPUNDOWN123'); @@ -389,6 +415,9 @@ describe('DisksService', () => { if (key === 'store.emhttp.disks') { return []; } + if (key === 'store.emhttp.devices') { + return []; + } return defaultValue; }); @@ -413,6 +442,9 @@ describe('DisksService', () => { if (key === 'store.emhttp.disks') { return disksWithSpaces; } + if (key === 'store.emhttp.devices') { + return mockEmhttpDevices; + } return defaultValue; }); @@ -453,6 +485,7 @@ describe('DisksService', () => { // Verify we're accessing the state through ConfigService expect(configService.get).toHaveBeenCalledWith('store.emhttp.disks', []); + expect(configService.get).toHaveBeenCalledWith('store.emhttp.devices', []); }); it('should handle empty disk layout or block devices', async () => { diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts index 1401be9eeb..5b2f9096e3 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -41,10 +41,66 @@ const SmartDataSchema = z.object({ .optional() .nullable(), }); +interface EmhttpDevice { + id?: string; + device?: string; +} + +interface EmhttpDeviceRecord { + id?: unknown; + device?: unknown; +} + +const normalizeDeviceName = (value: string | undefined): string => { + if (!value) { + return ''; + } + return value.startsWith('/dev/') ? value.slice('/dev/'.length) : value; +}; @Injectable() export class DisksService { constructor(private readonly configService: ConfigService) {} + + private getEmhttpDevices(): EmhttpDevice[] { + const rawDevicesValue = this.configService.get('store.emhttp.devices', []); + const rawDevices = Array.isArray(rawDevicesValue) ? rawDevicesValue : []; + const emhttpDevices: EmhttpDevice[] = []; + + for (const raw of rawDevices) { + if (!raw || typeof raw !== 'object') { + continue; + } + + const record = raw as EmhttpDeviceRecord; + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const device = typeof record.device === 'string' ? record.device.trim() : ''; + + if (!id || !device) { + continue; + } + + emhttpDevices.push({ + id, + device: normalizeDeviceName(device), + }); + } + + return emhttpDevices; + } + + private findEmhttpDevice( + disk: Systeminformation.DiskLayoutData, + emhttpDevices: EmhttpDevice[] + ): EmhttpDevice | undefined { + const normalizedSystemDevice = normalizeDeviceName(disk.device); + if (!normalizedSystemDevice) { + return undefined; + } + + return emhttpDevices.find((emhttpDevice) => emhttpDevice.device === normalizedSystemDevice); + } + public async getTemperature(device: string): Promise { try { const { stdout } = await execa('smartctl', ['-n', 'standby', '-A', '-j', device]); @@ -93,7 +149,8 @@ export class DisksService { private async parseDisk( disk: Systeminformation.DiskLayoutData, partitionsToParse: Systeminformation.BlockDevicesData[], - arrayDisks: ArrayDisk[] + arrayDisks: ArrayDisk[], + emhttpDevices: EmhttpDevice[] ): Promise> { const partitions = partitionsToParse // Only get partitions from this disk @@ -158,10 +215,12 @@ export class DisksService { } const arrayDisk = arrayDisks.find((d) => d.id.trim() === disk.serialNum.trim()); + const emhttpDevice = this.findEmhttpDevice(disk, emhttpDevices); return { ...disk, id: disk.serialNum, // Ensure id is set + emhttpDeviceId: emhttpDevice?.id, smartStatus: DiskSmartStatus[disk.smartStatus?.toUpperCase() as keyof typeof DiskSmartStatus] ?? DiskSmartStatus.UNKNOWN, @@ -179,8 +238,9 @@ export class DisksService { devices.filter((device) => device.type === 'part') ); const arrayDisks = this.configService.get('store.emhttp.disks', []); + const emhttpDevices = this.getEmhttpDevices(); const { data } = await batchProcess(await diskLayout(), async (disk) => - this.parseDisk(disk, partitions, arrayDisks) + this.parseDisk(disk, partitions, arrayDisks, emhttpDevices) ); return data; } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index af2a98d14e..ee26ee8cad 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql'; import { Onboarding } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { Theme } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; +import { OnboardingInternalBootResult } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; import { PluginInstallOperation } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js'; @@ -77,6 +78,11 @@ export class OnboardingMutations { description: 'Clear onboarding override state and reload from disk', }) clearOnboardingOverride!: Onboarding; + + @Field(() => OnboardingInternalBootResult, { + description: 'Create and configure internal boot pool via emcmd operations', + }) + createInternalBootPool!: OnboardingInternalBootResult; } @ObjectType({ diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts new file mode 100644 index 0000000000..a57700b50a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts @@ -0,0 +1,237 @@ +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { getters } from '@app/store/index.js'; +import { loadStateFileSync } from '@app/store/services/state-file-loader.js'; +import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, +})); + +vi.mock('@app/store/services/state-file-loader.js', () => ({ + loadStateFileSync: vi.fn(), +})); + +describe('OnboardingInternalBootService', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getters.emhttp).mockReturnValue({ + devices: [], + disks: [], + } as unknown as ReturnType); + }); + + it('runs the internal boot emcmd sequence and returns success', async () => { + vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); + const service = new OnboardingInternalBootService(); + + const result = await service.createInternalBootPool({ + poolName: 'cache', + devices: ['disk-1', 'disk-2'], + bootSizeMiB: 16384, + updateBios: false, + }); + + expect(result.ok).toBe(true); + expect(result.code).toBe(0); + expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(5); + expect(vi.mocked(execa)).not.toHaveBeenCalled(); + expect(vi.mocked(emcmd)).toHaveBeenNthCalledWith( + 1, + { debug: 'cmdCreatePool,cmdAssignDisk,cmdMakeBootable' }, + { waitForToken: true } + ); + expect(vi.mocked(emcmd)).toHaveBeenNthCalledWith( + 2, + { cmdCreatePool: 'apply', poolName: 'cache', poolSlots: '2' }, + { waitForToken: true } + ); + expect(vi.mocked(emcmd)).toHaveBeenNthCalledWith( + 3, + { cmdAssignDisk: 'apply', diskName: 'cache', diskId: 'disk-1' }, + { waitForToken: true } + ); + expect(vi.mocked(emcmd)).toHaveBeenNthCalledWith( + 4, + { cmdAssignDisk: 'apply', diskName: 'cache2', diskId: 'disk-2' }, + { waitForToken: true } + ); + expect(vi.mocked(emcmd)).toHaveBeenNthCalledWith( + 5, + { + cmdMakeBootable: 'apply', + poolName: 'cache', + poolBootSize: '16384', + }, + { waitForToken: true } + ); + }); + + it('runs efibootmgr update flow when updateBios is requested', async () => { + vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); + vi.mocked(getters.emhttp).mockReturnValue({ + devices: [{ id: 'disk-1', device: 'sdb' }], + disks: [{ type: 'FLASH', device: 'sda' }], + } as unknown as ReturnType); + vi.mocked(execa) + .mockResolvedValueOnce({ + stdout: 'Boot0001* Old Entry', + stderr: '', + exitCode: 0, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 0, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 0, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 0, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: 'Boot0003* Unraid Internal Boot - disk-1\nBoot0004* Unraid Flash', + stderr: '', + exitCode: 0, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 0, + } as Awaited>); + const service = new OnboardingInternalBootService(); + + const result = await service.createInternalBootPool({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }); + + expect(result.ok).toBe(true); + expect(result.code).toBe(0); + expect(result.output).toContain('BIOS boot entry updates completed successfully.'); + expect(vi.mocked(emcmd)).toHaveBeenCalledTimes(4); + expect(vi.mocked(loadStateFileSync)).not.toHaveBeenCalled(); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith(1, 'efibootmgr', [], { reject: false }); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith(2, 'efibootmgr', ['-b', '0001', '-B'], { + reject: false, + }); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith( + 3, + 'efibootmgr', + [ + '-c', + '-d', + '/dev/sdb', + '-p', + '2', + '-L', + 'Unraid Internal Boot - disk-1', + '-l', + '\\EFI\\BOOT\\BOOTX64.EFI', + ], + { reject: false } + ); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith( + 4, + 'efibootmgr', + ['-c', '-d', '/dev/sda', '-p', '1', '-L', 'Unraid Flash'], + { reject: false } + ); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith(5, 'efibootmgr', [], { reject: false }); + expect(vi.mocked(execa)).toHaveBeenNthCalledWith( + 6, + 'efibootmgr', + ['-o', '0003,0004', '-n', '0003'], + { reject: false } + ); + }); + + it('returns success and warning output when efibootmgr updates fail', async () => { + vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); + vi.mocked(execa) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Permission denied', + exitCode: 1, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'No such file or directory', + exitCode: 1, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: '', + stderr: '', + exitCode: 1, + } as Awaited>); + const service = new OnboardingInternalBootService(); + + const result = await service.createInternalBootPool({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }); + + expect(result.ok).toBe(true); + expect(result.code).toBe(0); + expect(result.output).toContain('efibootmgr failed for'); + expect(result.output).toContain( + 'BIOS boot entry updates completed with warnings; manual BIOS boot order changes may still be required.' + ); + }); + + it('returns validation error for duplicate devices', async () => { + const service = new OnboardingInternalBootService(); + + const result = await service.createInternalBootPool({ + poolName: 'cache', + devices: ['disk-1', 'disk-1'], + bootSizeMiB: 16384, + updateBios: false, + }); + + expect(result).toEqual({ + ok: false, + code: 2, + output: 'mkbootpool: duplicate device id: disk-1', + }); + expect(vi.mocked(emcmd)).not.toHaveBeenCalled(); + }); + + it('returns failure output when emcmd command throws', async () => { + vi.mocked(emcmd).mockRejectedValue(new Error('socket failure')); + const service = new OnboardingInternalBootService(); + + const result = await service.createInternalBootPool({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: false, + }); + + expect(result.ok).toBe(false); + expect(result.code).toBe(1); + expect(result.output).toContain('mkbootpool: command failed or timed out'); + expect(result.output).toContain('socket failure'); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.ts new file mode 100644 index 0000000000..622a735f9a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.ts @@ -0,0 +1,411 @@ +import { Injectable } from '@nestjs/common'; + +import { execa } from 'execa'; + +import type { + CreateInternalBootPoolInput, + OnboardingInternalBootResult, +} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { withTimeout } from '@app/core/utils/misc/with-timeout.js'; +import { getters } from '@app/store/index.js'; +import { loadStateFileSync } from '@app/store/services/state-file-loader.js'; +import { StateFileKey } from '@app/store/types.js'; +import { ArrayDiskType } from '@app/unraid-api/graph/resolvers/array/array.model.js'; + +const INTERNAL_BOOT_COMMAND_TIMEOUT_MS = 180000; +const EFI_BOOT_PATH = '\\EFI\\BOOT\\BOOTX64.EFI'; + +type EmhttpDeviceRecord = { + id: string; + device: string; +}; + +const isEmhttpDeviceRecord = (value: unknown): value is EmhttpDeviceRecord => { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as { id?: unknown; device?: unknown }; + return typeof record.id === 'string' && typeof record.device === 'string'; +}; + +@Injectable() +export class OnboardingInternalBootService { + private async runStep( + commandText: string, + command: Record, + output: string[] + ): Promise { + output.push(`Running: emcmd ${commandText}`); + await withTimeout( + emcmd(command, { waitForToken: true }), + INTERNAL_BOOT_COMMAND_TIMEOUT_MS, + `internal boot (${commandText})` + ); + } + + private hasDuplicateDevices(devices: string[]): string | null { + const seen = new Set(); + for (const device of devices) { + if (seen.has(device)) { + return device; + } + seen.add(device); + } + return null; + } + + private shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; + } + + private commandOutputLines(stdout: string, stderr: string): string[] { + const merged = [stdout, stderr].filter((value) => value.length > 0).join('\n'); + if (merged.length === 0) { + return []; + } + return merged.split('\n'); + } + + private parseBootLabelMap(lines: string[]): Map { + const labels = new Map(); + for (const line of lines) { + const match = line.match(/^Boot([0-9A-Fa-f]{4})\*?\s+(.+)$/); + if (!match) { + continue; + } + const bootNumber = match[1]?.toUpperCase(); + const labelText = match[2]?.trim(); + if (bootNumber && labelText) { + labels.set(labelText, bootNumber); + } + } + return labels; + } + + private async runEfiBootMgr( + args: string[], + output: string[] + ): Promise<{ + exitCode: number; + lines: string[]; + }> { + const commandText = args.map((arg) => this.shellQuote(arg)).join(' '); + output.push(`Running: efibootmgr${commandText.length > 0 ? ` ${commandText}` : ''}`); + try { + const result = await execa('efibootmgr', args, { reject: false }); + const lines = this.commandOutputLines(result.stdout, result.stderr); + if (lines.length > 0) { + output.push(...lines); + } + return { + exitCode: result.exitCode ?? 1, + lines, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.push(message); + return { + exitCode: 1, + lines: [], + }; + } + } + + private ensureEmhttpBootContext(): void { + const emhttpState = getters.emhttp(); + const hasDevices = Array.isArray(emhttpState.devices) && emhttpState.devices.length > 0; + const hasDisks = Array.isArray(emhttpState.disks) && emhttpState.disks.length > 0; + if (!hasDevices) { + loadStateFileSync(StateFileKey.devs); + } + if (!hasDisks) { + loadStateFileSync(StateFileKey.disks); + } + } + + private getDeviceMapFromEmhttpState(): Map { + const emhttpState = getters.emhttp(); + const rawDevices = Array.isArray(emhttpState.devices) ? emhttpState.devices : []; + const devicesById = new Map(); + + for (const rawDevice of rawDevices) { + if (!isEmhttpDeviceRecord(rawDevice)) { + continue; + } + const id = rawDevice.id.trim(); + const device = rawDevice.device.trim(); + if (id.length > 0 && device.length > 0) { + devicesById.set(id, device); + } + } + + return devicesById; + } + + private getFlashDeviceFromEmhttpState(): string | null { + const emhttpState = getters.emhttp(); + const emhttpDisks = Array.isArray(emhttpState.disks) ? emhttpState.disks : []; + for (const disk of emhttpDisks) { + if (!disk || typeof disk !== 'object') { + continue; + } + if (disk.type !== ArrayDiskType.FLASH) { + continue; + } + const device = typeof disk.device === 'string' ? disk.device.trim() : ''; + if (device.length > 0) { + return device; + } + } + return null; + } + + private async deleteExistingBootEntries( + bootLabelMap: Map, + output: string[] + ): Promise { + let hadFailures = false; + for (const bootNumber of bootLabelMap.values()) { + const deleteResult = await this.runEfiBootMgr(['-b', bootNumber, '-B'], output); + if (deleteResult.exitCode !== 0) { + hadFailures = true; + output.push( + `efibootmgr failed to delete boot entry ${bootNumber} (rc=${deleteResult.exitCode})` + ); + } + } + return hadFailures; + } + + private resolveBootDevicePath( + bootDevice: string, + devsById: Map + ): { bootId: string; devicePath: string } | null { + const bootId = bootDevice; + let device = bootDevice; + if (device === '' || devsById.has(device)) { + const mapped = devsById.get(device); + if (mapped) { + device = mapped; + } + } + if (device === '') { + return null; + } + return { bootId, devicePath: `/dev/${device}` }; + } + + private async createInternalBootEntries( + devices: string[], + devsById: Map, + output: string[] + ): Promise { + let hadFailures = false; + for (const bootDevice of devices) { + const resolved = this.resolveBootDevicePath(bootDevice, devsById); + if (!resolved) { + continue; + } + + const createResult = await this.runEfiBootMgr( + [ + '-c', + '-d', + resolved.devicePath, + '-p', + '2', + '-L', + `Unraid Internal Boot - ${resolved.bootId}`, + '-l', + EFI_BOOT_PATH, + ], + output + ); + if (createResult.exitCode !== 0) { + hadFailures = true; + output.push( + `efibootmgr failed for ${this.shellQuote(resolved.devicePath)} (rc=${createResult.exitCode})` + ); + } + } + return hadFailures; + } + + private async createFlashBootEntry(output: string[]): Promise { + const flashDevice = this.getFlashDeviceFromEmhttpState(); + if (!flashDevice) { + return false; + } + + const device = flashDevice.trim(); + const devicePath = `/dev/${device}`; + const flashResult = await this.runEfiBootMgr( + ['-c', '-d', devicePath, '-p', '1', '-L', 'Unraid Flash'], + output + ); + if (flashResult.exitCode !== 0) { + output.push(`efibootmgr failed for flash (rc=${flashResult.exitCode})`); + return true; + } + return false; + } + + private buildDesiredBootOrder(devices: string[], labelMap: Map): string[] { + const desiredOrder: string[] = []; + + for (const bootId of devices) { + const expectedLabel = `Unraid Internal Boot - ${bootId}`.toLowerCase(); + for (const [labelText, bootNumber] of labelMap.entries()) { + if (labelText.toLowerCase().includes(expectedLabel)) { + desiredOrder.push(bootNumber); + break; + } + } + } + + for (const [labelText, bootNumber] of labelMap.entries()) { + if (labelText.toLowerCase().includes('unraid flash')) { + desiredOrder.push(bootNumber); + break; + } + } + + return [...new Set(desiredOrder.filter((entry) => entry.length > 0))]; + } + + private async updateBootOrder(devices: string[], output: string[]): Promise { + const currentEntries = await this.runEfiBootMgr([], output); + if (currentEntries.exitCode !== 0) { + return true; + } + + const labelMap = this.parseBootLabelMap(currentEntries.lines); + const uniqueOrder = this.buildDesiredBootOrder(devices, labelMap); + if (uniqueOrder.length === 0) { + return false; + } + + const nextBoot = uniqueOrder[0]; + const orderArgs = ['-o', uniqueOrder.join(',')]; + if (nextBoot) { + orderArgs.push('-n', nextBoot); + } + + const orderResult = await this.runEfiBootMgr(orderArgs, output); + if (orderResult.exitCode !== 0) { + output.push(`efibootmgr failed to set boot order (rc=${orderResult.exitCode})`); + return true; + } + return false; + } + + private async updateBiosBootEntries( + devices: string[], + output: string[] + ): Promise<{ hadFailures: boolean }> { + let hadFailures = false; + this.ensureEmhttpBootContext(); + const devsById = this.getDeviceMapFromEmhttpState(); + + const existingEntries = await this.runEfiBootMgr([], output); + if (existingEntries.exitCode === 0) { + const bootLabelMap = this.parseBootLabelMap(existingEntries.lines); + hadFailures = (await this.deleteExistingBootEntries(bootLabelMap, output)) || hadFailures; + } + + hadFailures = (await this.createInternalBootEntries(devices, devsById, output)) || hadFailures; + hadFailures = (await this.createFlashBootEntry(output)) || hadFailures; + hadFailures = (await this.updateBootOrder(devices, output)) || hadFailures; + + return { hadFailures }; + } + + async createInternalBootPool( + input: CreateInternalBootPoolInput + ): Promise { + const output: string[] = []; + if (input.reboot) { + output.push( + 'Note: reboot was requested; onboarding handles reboot separately after internal boot setup.' + ); + } + + const duplicateDevice = this.hasDuplicateDevices(input.devices); + if (duplicateDevice) { + return { + ok: false, + code: 2, + output: `mkbootpool: duplicate device id: ${duplicateDevice}`, + }; + } + + try { + await this.runStep( + 'debug=cmdCreatePool,cmdAssignDisk,cmdMakeBootable', + { debug: 'cmdCreatePool,cmdAssignDisk,cmdMakeBootable' }, + output + ); + + await this.runStep( + `cmdCreatePool=apply&poolName=${input.poolName}&poolSlots=${input.devices.length}`, + { + cmdCreatePool: 'apply', + poolName: input.poolName, + poolSlots: String(input.devices.length), + }, + output + ); + + for (const [index, diskId] of input.devices.entries()) { + const slot = index + 1; + const diskName = slot === 1 ? input.poolName : `${input.poolName}${slot}`; + await this.runStep( + `cmdAssignDisk=apply&diskName=${diskName}&diskId=${diskId}`, + { + cmdAssignDisk: 'apply', + diskName, + diskId, + }, + output + ); + } + + await this.runStep( + `cmdMakeBootable=apply&poolName=${input.poolName}&poolBootSize=${input.bootSizeMiB}`, + { + cmdMakeBootable: 'apply', + poolName: input.poolName, + poolBootSize: String(input.bootSizeMiB), + }, + output + ); + + if (input.updateBios) { + output.push('Applying BIOS boot entry updates...'); + const biosUpdateResult = await this.updateBiosBootEntries(input.devices, output); + output.push( + biosUpdateResult.hadFailures + ? 'BIOS boot entry updates completed with warnings; manual BIOS boot order changes may still be required.' + : 'BIOS boot entry updates completed successfully.' + ); + } + + return { + ok: true, + code: 0, + output: output.join('\n') || 'No output', + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.push('mkbootpool: command failed or timed out'); + output.push(message); + return { + ok: false, + code: 1, + output: output.join('\n') || 'mkbootpool: command failed or timed out', + }; + } + } +} diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts index 8943713285..dadfe46f06 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -1,7 +1,20 @@ -import { Field, InputType } from '@nestjs/graphql'; +import { Field, InputType, Int, ObjectType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsIn, IsOptional, IsString, ValidateNested } from 'class-validator'; +import { + ArrayMaxSize, + ArrayMinSize, + IsBoolean, + IsEnum, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + Min, + ValidateNested, +} from 'class-validator'; import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; @@ -261,3 +274,50 @@ export class OnboardingOverrideInput { @IsEnum(RegistrationState) registrationState?: RegistrationState; } + +@InputType({ + description: 'Input for creating an internal boot pool during onboarding', +}) +export class CreateInternalBootPoolInput { + @Field(() => String) + @IsString() + @Matches(/^[a-z](?:[a-z0-9~._-]*[a-z_-])?$/, { + message: 'Pool name must match Unraid naming requirements', + }) + poolName!: string; + + @Field(() => [String]) + @ArrayMinSize(1) + @ArrayMaxSize(4) + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + devices!: string[]; + + @Field(() => Int) + @IsInt() + @Min(0) + bootSizeMiB!: number; + + @Field(() => Boolean) + @IsBoolean() + updateBios!: boolean; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + reboot?: boolean; +} + +@ObjectType({ + description: 'Result of attempting internal boot pool setup', +}) +export class OnboardingInternalBootResult { + @Field(() => Boolean) + ok!: boolean; + + @Field(() => Int, { nullable: true }) + code?: number; + + @Field(() => String) + output!: string; +} diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts index 8961aa8367..97db6ca47f 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; +import { CreateInternalBootPoolInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; describe('OnboardingMutationsResolver', () => { @@ -22,6 +23,10 @@ describe('OnboardingMutationsResolver', () => { clearActivationDataCache: vi.fn(), }; + const onboardingInternalBootService = { + createInternalBootPool: vi.fn(), + }; + let resolver: OnboardingMutationsResolver; beforeEach(() => { @@ -43,7 +48,8 @@ describe('OnboardingMutationsResolver', () => { resolver = new OnboardingMutationsResolver( onboardingTracker as any, onboardingOverrides as any, - onboardingService as any + onboardingService as any, + onboardingInternalBootService as any ); }); @@ -179,4 +185,27 @@ describe('OnboardingMutationsResolver', () => { expect(onboardingService.clearActivationDataCache).toHaveBeenCalledTimes(1); expect(result.status).toBe(OnboardingStatus.INCOMPLETE); }); + + it('delegates createInternalBootPool to onboarding internal boot service', async () => { + onboardingInternalBootService.createInternalBootPool.mockResolvedValue({ + ok: true, + code: 0, + output: 'done', + }); + + const input: CreateInternalBootPoolInput = { + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }; + const result = await resolver.createInternalBootPool(input); + + expect(onboardingInternalBootService.createInternalBootPool).toHaveBeenCalledWith(input); + expect(result).toEqual({ + ok: true, + code: 0, + output: 'done', + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts index 606d0c6deb..358347fdcf 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -12,15 +12,21 @@ import { } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js'; import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; -import { OnboardingOverrideInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; +import { + CreateInternalBootPoolInput, + OnboardingInternalBootResult, + OnboardingOverrideInput, +} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; @Resolver(() => OnboardingMutations) export class OnboardingMutationsResolver { constructor( private readonly onboardingTracker: OnboardingTrackerService, private readonly onboardingOverrides: OnboardingOverrideService, - private readonly onboardingService: OnboardingService + private readonly onboardingService: OnboardingService, + private readonly onboardingInternalBootService: OnboardingInternalBootService ) {} /** @@ -109,4 +115,17 @@ export class OnboardingMutationsResolver { this.onboardingService.clearActivationDataCache(); return this.buildOnboardingResponse(); } + + @ResolveField(() => OnboardingInternalBootResult, { + description: 'Create and configure internal boot pool via emcmd operations', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async createInternalBootPool( + @Args('input') input: CreateInternalBootPoolInput + ): Promise { + return this.onboardingInternalBootService.createInternalBootPool(input); + } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index dc689376f2..7001406539 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -20,6 +20,7 @@ import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.m import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; +import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; @@ -75,6 +76,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; OnlineResolver, OwnerResolver, OnboardingMutationsResolver, + OnboardingInternalBootService, RegistrationResolver, RootMutationsResolver, ServerResolver, diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts index f91faf5c63..f2f5e0807e 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts @@ -131,15 +131,44 @@ describe('ServerService', () => { }); }); + it('skips emcmd when identity values are unchanged', async () => { + const result = await service.updateServerIdentity('Tower', 'Tower comment'); + + expect(emcmd).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + name: 'Tower', + comment: 'Tower comment', + lanip: '192.168.1.10', + }); + }); + + it('skips emcmd when sysModel is unchanged', async () => { + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + name: 'Tower', + fsState: 'Stopped', + regGuid: 'GUID-123', + port: '80', + comment: 'Tower comment', + sysModel: 'Model X200', + }, + networks: [{ ipaddr: ['192.168.1.10'] }], + } as unknown as ReturnType); + + await service.updateServerIdentity('Tower', 'Tower comment', 'Model X200'); + + expect(emcmd).not.toHaveBeenCalled(); + }); + it('includes SYS_MODEL when provided', async () => { - await service.updateServerIdentity('Tower', 'Primary host', 'Storinator'); + await service.updateServerIdentity('Tower', 'Primary host', 'Model X200'); expect(emcmd).toHaveBeenCalledWith( { changeNames: 'Apply', NAME: 'Tower', COMMENT: 'Primary host', - SYS_MODEL: 'Storinator', + SYS_MODEL: 'Model X200', }, { waitForToken: true } ); diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.ts index 9ff0b875ce..47ea014ed9 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.ts @@ -14,6 +14,36 @@ import { export class ServerService { private readonly logger = new Logger(ServerService.name); + private buildServerResponse( + emhttpState: ReturnType, + name: string, + comment: string + ): Server { + const guid = emhttpState.var?.regGuid ?? ''; + const lanip = emhttpState.networks?.[0]?.ipaddr?.[0] ?? ''; + const port = emhttpState.var?.port ?? ''; + const owner: ProfileModel = { + id: 'local', + username: 'root', + url: '', + avatar: '', + }; + + return { + id: 'local', + owner, + guid, + apikey: '', + name, + comment, + status: ServerStatus.ONLINE, + wanip: '', + lanip, + localurl: lanip ? `http://${lanip}${port ? `:${port}` : ''}` : '', + remoteurl: '', + }; + } + /** * Updates the server identity (name and comment/description). * The array must be stopped to change the server name. @@ -58,7 +88,16 @@ export class ServerService { // Actually, UI only disables it if array is not stopped. // Let's check current name. const currentEmhttp = getters.emhttp(); - const currentName = currentEmhttp.var?.name; + const currentName = currentEmhttp.var?.name ?? ''; + const currentComment = currentEmhttp.var?.comment ?? ''; + const currentSysModel = currentEmhttp.var?.sysModel ?? ''; + const nextComment = comment ?? currentComment; + const nextSysModel = sysModel ?? currentSysModel; + + if (name === currentName && nextComment === currentComment && nextSysModel === currentSysModel) { + this.logger.log('Server identity unchanged; skipping emcmd update.'); + return this.buildServerResponse(currentEmhttp, currentName, currentComment); + } if (name !== currentName) { const fsState = currentEmhttp.var?.fsState; @@ -83,30 +122,8 @@ export class ServerService { await emcmd(params, { waitForToken: true }); this.logger.log('Server identity updated successfully via emcmd.'); const latestEmhttp = getters.emhttp(); - const guid = latestEmhttp.var?.regGuid ?? ''; - const lanip = latestEmhttp.networks?.[0]?.ipaddr?.[0] ?? ''; - const port = latestEmhttp.var?.port ?? ''; - const nextComment = comment ?? latestEmhttp.var?.comment; - const owner: ProfileModel = { - id: 'local', - username: 'root', - url: '', - avatar: '', - }; - - return { - id: 'local', - owner, - guid, - apikey: '', - name, - comment: nextComment, - status: ServerStatus.ONLINE, - wanip: '', - lanip, - localurl: lanip ? `http://${lanip}:${port}` : '', - remoteurl: '', - }; + const responseComment = comment ?? latestEmhttp.var?.comment ?? currentComment; + return this.buildServerResponse(latestEmhttp, name, responseComment); } catch (error) { this.logger.error('Failed to update server identity', error); throw new GraphQLError('Failed to update server identity'); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts index 53dee9337c..1097db4f15 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.model.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.model.ts @@ -428,6 +428,15 @@ export class Vars extends Node { @Field({ nullable: true }) fsState?: string; + @Field({ nullable: true }) + bootEligible?: boolean; + + @Field({ nullable: true }) + enableBootTransfer?: string; + + @Field({ nullable: true }) + reservedNames?: string; + @Field({ nullable: true, description: 'Human friendly string of array events happening' }) fsProgress?: string; diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts b/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts index 74a48548f4..b24bd0e835 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.service.spec.ts @@ -40,7 +40,7 @@ describe('VarsService', () => { useTelnet: false, porttelnet: 23, useUpnp: false, - useSsl: 'no', + useSsl: true, port: 80, portssl: 443, localTld: 'local', @@ -97,7 +97,7 @@ describe('VarsService', () => { USE_SSH: 'yes', PORTSSH: '2222', USE_UPNP: 'no', - USE_SSL: 'no', + USE_SSL: 'yes', PORT: '80', PORTSSL: '443', LOCAL_TLD: 'local', @@ -139,6 +139,24 @@ describe('VarsService', () => { }); }); + it('preserves auto SSL mode when updating SSH settings', async () => { + currentVarState = { + ...currentVarState, + useSsl: null, + }; + + await service.updateSshSettings(true, 22); + + expect(emcmd).toHaveBeenCalledWith( + expect.objectContaining({ + USE_SSL: 'auto', + USE_SSH: 'yes', + PORTSSH: '22', + }), + { waitForToken: false } + ); + }); + it('swallows emcmd errors and returns last observed vars when unverifiable', async () => { vi.mocked(emcmd).mockRejectedValue(new Error('connection reset')); diff --git a/api/src/unraid-api/graph/resolvers/vars/vars.service.ts b/api/src/unraid-api/graph/resolvers/vars/vars.service.ts index b83b8a1010..2ec53dd416 100644 --- a/api/src/unraid-api/graph/resolvers/vars/vars.service.ts +++ b/api/src/unraid-api/graph/resolvers/vars/vars.service.ts @@ -74,7 +74,31 @@ export class VarsService { // Helper to formatting values for emcmd (converting booleans to yes/no) const formatBool = (val: boolean | undefined | null) => (val ? 'yes' : 'no'); - const formatVal = (val: any) => (val !== undefined && val !== null ? String(val) : ''); + const formatVal = (val: unknown) => (val !== undefined && val !== null ? String(val) : ''); + const formatSslMode = (val: unknown): string => { + if (typeof val === 'string') { + const normalized = val.trim().toLowerCase(); + if (normalized === 'yes' || normalized === 'no' || normalized === 'auto') { + return normalized; + } + if (normalized === 'true') { + return 'yes'; + } + if (normalized === 'false') { + return 'no'; + } + } + + if (typeof val === 'boolean') { + return val ? 'yes' : 'no'; + } + + if (val === null) { + return 'auto'; + } + + return 'no'; + }; // Construct parameters based on ManagementAccess.page form fields // We preserve existing values for other fields to avoid overwriting them with defaults/empty @@ -89,7 +113,7 @@ export class VarsService { USE_SSH: formatBool(enabled), // New Value PORTSSH: formatVal(port), // New Value USE_UPNP: formatBool(currentVars.useUpnp), // defaults to 'no' - USE_SSL: formatVal(currentVars.useSsl || 'no'), + USE_SSL: formatSslMode(currentVars.useSsl), PORT: formatVal(currentVars.port || '80'), PORTSSL: formatVal(currentVars.portssl || '443'), LOCAL_TLD: formatVal(currentVars.localTld || 'local'), diff --git a/docs/onboarding-internal-boot-port-differences.md b/docs/onboarding-internal-boot-port-differences.md new file mode 100644 index 0000000000..4aa778d171 --- /dev/null +++ b/docs/onboarding-internal-boot-port-differences.md @@ -0,0 +1,25 @@ +# Onboarding Internal Boot Port: Non-1:1 Differences + +This list tracks behavior that is intentionally or currently different from the webgui implementation in commit `edff0e5202c0efeaa613efb8dfc599453d0fe5cb`. + +## Current Differences + +- Data source for setup context: + - Webgui: `Main/PoolDevices`/websocket-rendered context + `devs.ini`-based behavior. + - Onboarding: existing GraphQL (`array`, `vars`, `shares`, `disks`). + +- Device option labeling: + - Webgui formats labels via PHP helpers. + - Onboarding formats labels in Vue (` - ()`). + +- Dialog auto-open via URL flag: + - Webgui supports `?createbootpool`. + - Onboarding step does not support URL-triggered auto-open. + +- Reboot flow: + - Webgui dialog path is "Activate and Reboot" in one flow. + - Onboarding applies `mkbootpool` without reboot in summary, then exposes reboot as a separate next-step action. + +- Internal-boot visibility source: + - Onboarding hides the step using `vars.enableBootTransfer` (`no` means already internal boot). + - This matches `var.ini` semantics, but is still API-driven rather than websocket-rendered page context. diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts index feb395ca56..5830012531 100644 --- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts @@ -349,7 +349,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45', comment: 'Primary storage node' }, + system: { serverName: 'Server01', comment: 'Primary storage node' }, }, }, server: { name: 'Tower', comment: 'Media server' }, @@ -366,7 +366,7 @@ describe('OnboardingCoreSettingsStep', () => { expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ - serverName: 'Storinator45', + serverName: 'Server01', serverDescription: 'Primary storage node', }); expect(onComplete).toHaveBeenCalledTimes(1); @@ -383,7 +383,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45' }, + system: { serverName: 'Server01' }, }, }, server: { name: 'Tower', comment: 'Media server' }, @@ -400,7 +400,7 @@ describe('OnboardingCoreSettingsStep', () => { expect(setCoreSettingsMock).toHaveBeenCalledTimes(1); expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({ - serverName: 'Storinator45', + serverName: 'Server01', serverDescription: '', }); expect(onComplete).toHaveBeenCalledTimes(1); @@ -417,7 +417,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45', comment: 'Primary storage node' }, + system: { serverName: 'Server01', comment: 'Primary storage node' }, }, }, server: { name: '', comment: '' }, @@ -452,7 +452,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + system: { serverName: 'Server01', comment: 'Partner-provided comment' }, }, }, server: { name: 'TowerFromServer', comment: 'Comment from API' }, @@ -486,7 +486,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + system: { serverName: 'Server01', comment: 'Partner-provided comment' }, }, }, server: { name: 'TowerFromServer', comment: '' }, @@ -520,7 +520,7 @@ describe('OnboardingCoreSettingsStep', () => { data: { customization: { activationCode: { - system: { serverName: 'Storinator45', comment: 'Partner-provided comment' }, + system: { serverName: 'Server01', comment: 'Partner-provided comment' }, }, }, server: { name: 'TowerFromServer', comment: 'Comment from API' }, diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 811375b70a..e25f859a07 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -8,6 +8,7 @@ import { createTestI18n } from '../../utils/i18n'; const { mutateMock, + internalBootVisibilityResult, activationCodeModalStore, activationCodeDataStore, upgradeOnboardingStore, @@ -18,6 +19,13 @@ const { cleanupOnboardingStorageMock, } = vi.hoisted(() => ({ mutateMock: vi.fn().mockResolvedValue(undefined), + internalBootVisibilityResult: { + value: { + vars: { + enableBootTransfer: 'yes', + }, + }, + }, activationCodeModalStore: { isVisible: { value: true }, isTemporarilyBypassed: { value: false }, @@ -40,6 +48,7 @@ const { isVersionDrift: { value: false }, completedAtVersion: { value: null }, canDisplayOnboardingModal: { value: true }, + isPartnerBuild: { value: false }, refetchOnboarding: vi.fn().mockResolvedValue(undefined), }, onboardingDraftStore: { @@ -81,6 +90,11 @@ vi.mock('@heroicons/vue/24/solid', () => ({ })); vi.mock('@vue/apollo-composable', () => ({ + useQuery: () => ({ + result: internalBootVisibilityResult, + loading: { value: false }, + error: { value: null }, + }), useMutation: () => ({ mutate: mutateMock, }), @@ -97,6 +111,7 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({ stepComponents: { OVERVIEW: { template: '
' }, CONFIGURE_SETTINGS: { template: '
' }, + CONFIGURE_BOOT: { template: '
' }, ADD_PLUGINS: { template: '
' }, ACTIVATE_LICENSE: { template: '
' }, SUMMARY: { template: '
' }, @@ -144,12 +159,19 @@ describe('OnboardingModal.vue', () => { activationCodeModalStore.isTemporarilyBypassed.value = false; activationCodeDataStore.activationRequired.value = false; activationCodeDataStore.hasActivationCode.value = true; + activationCodeDataStore.isFreshInstall.value = true; activationCodeDataStore.registrationState.value = 'ENOKEYFILE'; upgradeOnboardingStore.shouldShowOnboarding.value = false; upgradeOnboardingStore.isVersionDrift.value = false; upgradeOnboardingStore.completedAtVersion.value = null; upgradeOnboardingStore.canDisplayOnboardingModal.value = true; + upgradeOnboardingStore.isPartnerBuild.value = false; onboardingDraftStore.currentStepIndex.value = 0; + internalBootVisibilityResult.value = { + vars: { + enableBootTransfer: 'yes', + }, + }; Object.defineProperty(window, 'location', { writable: true, @@ -196,6 +218,17 @@ describe('OnboardingModal.vue', () => { expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); }); + it('does not render when system is not a fresh install', () => { + activationCodeDataStore.isFreshInstall.value = false; + activationCodeModalStore.isVisible.value = false; + upgradeOnboardingStore.shouldShowOnboarding.value = true; + upgradeOnboardingStore.canDisplayOnboardingModal.value = true; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false); + }); + it('does not render when temporary bypass is active', () => { activationCodeModalStore.isVisible.value = true; activationCodeModalStore.isTemporarilyBypassed.value = true; @@ -226,7 +259,7 @@ describe('OnboardingModal.vue', () => { it('shows activation step for ENOKEYFILE1', () => { activationCodeDataStore.registrationState.value = 'ENOKEYFILE1'; - onboardingDraftStore.currentStepIndex.value = 3; + onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -235,7 +268,7 @@ describe('OnboardingModal.vue', () => { it('shows activation step for ENOKEYFILE2', () => { activationCodeDataStore.registrationState.value = 'ENOKEYFILE2'; - onboardingDraftStore.currentStepIndex.value = 3; + onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -244,7 +277,7 @@ describe('OnboardingModal.vue', () => { it('omits activation step for non-activation registration states', () => { activationCodeDataStore.registrationState.value = 'BASIC'; - onboardingDraftStore.currentStepIndex.value = 3; + onboardingDraftStore.currentStepIndex.value = 4; const wrapper = mountComponent(); @@ -252,6 +285,38 @@ describe('OnboardingModal.vue', () => { expect(wrapper.find('[data-testid="summary-step"]').exists()).toBe(true); }); + it('shows internal boot step for regular builds', () => { + onboardingDraftStore.currentStepIndex.value = 2; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(true); + }); + + it('hides internal boot step for partner builds', () => { + upgradeOnboardingStore.isPartnerBuild.value = true; + onboardingDraftStore.currentStepIndex.value = 2; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); + }); + + it('hides internal boot step when already booting internally', () => { + internalBootVisibilityResult.value = { + vars: { + enableBootTransfer: 'no', + }, + }; + onboardingDraftStore.currentStepIndex.value = 2; + + const wrapper = mountComponent(); + + expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(false); + expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true); + }); + it('opens exit confirmation when close button is clicked', async () => { const wrapper = mountComponent(); diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts new file mode 100644 index 0000000000..0b51cc787d --- /dev/null +++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts @@ -0,0 +1,111 @@ +import { reactive } from 'vue'; +import { flushPromises, mount } from '@vue/test-utils'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import OnboardingNextStepsStep from '~/components/Onboarding/steps/OnboardingNextStepsStep.vue'; +import { createTestI18n } from '../../utils/i18n'; + +const { draftStore, activationCodeDataStore, submitInternalBootRebootMock } = vi.hoisted(() => ({ + draftStore: { + internalBootApplySucceeded: false, + }, + activationCodeDataStore: { + partnerInfo: { + value: { + partner: { + manualUrl: null, + hardwareSpecsUrl: null, + supportUrl: null, + extraLinks: [], + }, + }, + }, + activationCode: { + value: null, + }, + }, + submitInternalBootRebootMock: vi.fn(), +})); + +vi.mock('@unraid/ui', () => ({ + BrandButton: { + props: ['text', 'disabled'], + emits: ['click'], + template: + '', + }, + Dialog: { + props: ['modelValue'], + template: '
', + }, +})); + +vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ + useOnboardingDraftStore: () => reactive(draftStore), +})); + +vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ + useActivationCodeDataStore: () => reactive(activationCodeDataStore), +})); + +vi.mock('~/components/Onboarding/composables/internalBoot', () => ({ + submitInternalBootReboot: submitInternalBootRebootMock, +})); + +describe('OnboardingNextStepsStep', () => { + beforeEach(() => { + vi.clearAllMocks(); + draftStore.internalBootApplySucceeded = false; + }); + + const mountComponent = () => { + const onComplete = vi.fn(); + const wrapper = mount(OnboardingNextStepsStep, { + props: { + onComplete, + showBack: true, + }, + global: { + plugins: [createTestI18n()], + }, + }); + + return { wrapper, onComplete }; + }; + + it('continues to dashboard when reboot is not required', async () => { + const { wrapper, onComplete } = mountComponent(); + + const button = wrapper.find('[data-testid="brand-button"]'); + await button.trigger('click'); + await flushPromises(); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); + }); + + it('shows reboot warning dialog and waits for confirmation', async () => { + draftStore.internalBootApplySucceeded = true; + const { wrapper, onComplete } = mountComponent(); + + const button = wrapper.find('[data-testid="brand-button"]'); + await button.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('Confirm Reboot'); + expect(wrapper.text()).toContain('Please do NOT remove your Unraid flash drive'); + expect(submitInternalBootRebootMock).not.toHaveBeenCalled(); + expect(onComplete).not.toHaveBeenCalled(); + + const confirmButton = wrapper + .findAll('button') + .find((candidate) => candidate.text().trim() === 'I Understand'); + expect(confirmButton).toBeTruthy(); + await confirmButton!.trigger('click'); + await flushPromises(); + + expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1); + expect(onComplete).not.toHaveBeenCalled(); + }); +}); diff --git a/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts index 8e7ab5b4b9..8f35e84131 100644 --- a/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingOverviewStep.test.ts @@ -20,7 +20,7 @@ const { refetchOnboardingMock: vi.fn().mockResolvedValue({}), partnerInfoRef: { value: { - partner: { name: '45Drives' }, + partner: { name: 'Partner' }, branding: { hasPartnerLogo: true, partnerLogoLightUrl: 'data:image/png;base64,AAA=', @@ -97,7 +97,7 @@ describe('OnboardingOverviewStep', () => { beforeEach(() => { vi.clearAllMocks(); partnerInfoRef.value = { - partner: { name: '45Drives' }, + partner: { name: 'Partner' }, branding: { hasPartnerLogo: true, partnerLogoLightUrl: 'data:image/png;base64,AAA=', @@ -127,7 +127,7 @@ describe('OnboardingOverviewStep', () => { expect(img.exists()).toBe(true); expect(img.attributes('src')).toBe('data:image/png;base64,AAA='); - expect(img.attributes('alt')).toBe('45Drives'); + expect(img.attributes('alt')).toBe('Partner'); }); it('falls back to default overview image when partner logo fails to load', async () => { diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index ec963ed304..2484ec58eb 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -9,6 +9,7 @@ import { UPDATE_SSH_SETTINGS_MUTATION, } from '@/components/Onboarding/graphql/coreSettings.mutations'; import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; +import { GET_INTERNAL_BOOT_CONTEXT_QUERY } from '@/components/Onboarding/graphql/getInternalBootContext.query'; import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/updateSystemTime.mutation'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -19,11 +20,13 @@ import { createTestI18n } from '../../utils/i18n'; const { draftStore, + setInternalBootApplySucceededMock, registrationStateRef, isFreshInstallRef, activationCodeRef, coreSettingsResult, coreSettingsError, + internalBootContextResult, installedPluginsResult, availableLanguagesResult, refetchInstalledPluginsMock, @@ -37,6 +40,7 @@ const { completeOnboardingMock, installLanguageMock, installPluginMock, + submitInternalBootCreationMock, useMutationMock, useQueryMock, } = vi.hoisted(() => ({ @@ -47,8 +51,22 @@ const { selectedTheme: 'white', selectedLanguage: 'en_US', useSsh: false, + bootMode: 'usb' as 'usb' | 'storage', selectedPlugins: new Set(), + internalBootSelection: null as { + poolName: string; + slotCount: number; + devices: string[]; + bootSizeMiB: number; + updateBios: boolean; + } | null, + internalBootInitialized: true, + internalBootSkipped: false, + internalBootApplySucceeded: false, }, + setInternalBootApplySucceededMock: vi.fn((value: boolean) => { + draftStore.internalBootApplySucceeded = value; + }), registrationStateRef: { value: 'ENOKEYFILE' }, isFreshInstallRef: { value: true }, activationCodeRef: { value: null as unknown }, @@ -56,6 +74,7 @@ const { value: null as unknown, }, coreSettingsError: { value: null as unknown }, + internalBootContextResult: { value: null as unknown }, installedPluginsResult: { value: { installedUnraidPlugins: [] as string[] } }, availableLanguagesResult: { value: { @@ -78,6 +97,7 @@ const { completeOnboardingMock: vi.fn().mockResolvedValue({}), installLanguageMock: vi.fn(), installPluginMock: vi.fn(), + submitInternalBootCreationMock: vi.fn(), useMutationMock: vi.fn(), useQueryMock: vi.fn(), })); @@ -124,7 +144,10 @@ vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({ })); vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({ - useOnboardingDraftStore: () => draftStore, + useOnboardingDraftStore: () => ({ + ...draftStore, + setInternalBootApplySucceeded: setInternalBootApplySucceededMock, + }), })); vi.mock('~/components/Onboarding/store/activationCodeData', () => ({ @@ -155,6 +178,10 @@ vi.mock('@/components/Onboarding/composables/usePluginInstaller', () => ({ }), })); +vi.mock('@/components/Onboarding/composables/internalBoot', () => ({ + submitInternalBootCreation: submitInternalBootCreationMock, +})); + vi.mock('@vue/apollo-composable', async () => { const actual = await vi.importActual('@vue/apollo-composable'); @@ -192,6 +219,9 @@ const setupApolloMocks = () => { if (doc === GET_CORE_SETTINGS_QUERY) { return { result: coreSettingsResult, error: coreSettingsError }; } + if (doc === GET_INTERNAL_BOOT_CONTEXT_QUERY) { + return { result: internalBootContextResult }; + } if (doc === INSTALLED_UNRAID_PLUGINS_QUERY) { return { result: installedPluginsResult, @@ -226,6 +256,16 @@ const clickApply = async (wrapper: ReturnType['wrapper']) const applyButton = buttons[buttons.length - 1]; await applyButton.trigger('click'); await flushPromises(); + + if (wrapper.text().includes('Confirm Drive Wipe')) { + const continueButton = wrapper + .findAll('button') + .find((button) => button.text().trim() === 'Continue'); + expect(continueButton).toBeTruthy(); + await continueButton!.trigger('click'); + await flushPromises(); + } + await vi.runAllTimersAsync(); await flushPromises(); }; @@ -242,7 +282,12 @@ describe('OnboardingSummaryStep', () => { draftStore.selectedTheme = 'white'; draftStore.selectedLanguage = 'en_US'; draftStore.useSsh = false; + draftStore.bootMode = 'usb'; draftStore.selectedPlugins = new Set(); + draftStore.internalBootSelection = null; + draftStore.internalBootInitialized = true; + draftStore.internalBootSkipped = false; + draftStore.internalBootApplySucceeded = false; registrationStateRef.value = 'ENOKEYFILE'; isFreshInstallRef.value = true; @@ -255,6 +300,33 @@ describe('OnboardingSummaryStep', () => { systemTime: { timeZone: 'UTC' }, info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, }; + internalBootContextResult.value = { + array: { + boot: null, + parities: [], + disks: [], + caches: [], + }, + vars: { + bootEligible: true, + }, + disks: [ + { + device: '/dev/sda', + size: 500 * 1024 * 1024 * 1024, + emhttpDeviceId: 'diskA', + emhttpSectors: null, + emhttpSectorSize: null, + }, + { + device: '/dev/sdb', + size: 250 * 1024 * 1024 * 1024, + emhttpDeviceId: 'diskB', + emhttpSectors: null, + emhttpSectorSize: null, + }, + ], + }; installedPluginsResult.value = { installedUnraidPlugins: [] }; availableLanguagesResult.value = { customization: { @@ -281,6 +353,10 @@ describe('OnboardingSummaryStep', () => { status: PluginInstallStatus.SUCCEEDED, output: [], }); + submitInternalBootCreationMock.mockResolvedValue({ + ok: true, + output: 'ok', + }); refetchInstalledPluginsMock.mockResolvedValue(undefined); refetchOnboardingMock.mockResolvedValue(undefined); }); @@ -814,6 +890,46 @@ describe('OnboardingSummaryStep', () => { scenario.assertExpected(wrapper); }); + it('retries completeOnboarding after transient network errors when SSH changed', async () => { + draftStore.useSsh = true; + updateSshSettingsMock.mockResolvedValue({ + data: { + updateSshSettings: { id: 'vars', useSsh: true, portssh: 22 }, + }, + }); + completeOnboardingMock + .mockRejectedValueOnce(new Error('NetworkError when attempting to fetch resource.')) + .mockResolvedValueOnce({}); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(completeOnboardingMock).toHaveBeenCalledTimes(2); + expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).not.toContain('Could not mark onboarding complete right now'); + }); + + it('retries final identity update after transient network errors when SSH changed', async () => { + draftStore.useSsh = true; + draftStore.serverDescription = 'Primary host'; + updateSshSettingsMock.mockResolvedValue({ + data: { + updateSshSettings: { id: 'vars', useSsh: true, portssh: 22 }, + }, + }); + updateServerIdentityMock + .mockRejectedValueOnce(new Error('NetworkError when attempting to fetch resource.')) + .mockResolvedValueOnce({}); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(updateServerIdentityMock).toHaveBeenCalledTimes(2); + expect(wrapper.text()).toContain('Server Identity updated.'); + expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).not.toContain('Server identity request returned an error, continuing'); + }); + it('prefers best-effort result over timeout classification when completion fails', async () => { draftStore.selectedPlugins = new Set(['community-apps']); const timeoutError = new Error( @@ -917,4 +1033,171 @@ describe('OnboardingSummaryStep', () => { ); expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode'); }); + + it('always shows boot configuration section for USB boot mode', () => { + draftStore.bootMode = 'usb'; + draftStore.internalBootSelection = null; + draftStore.internalBootInitialized = true; + draftStore.internalBootSkipped = false; + + const { wrapper } = mountComponent(); + + expect(wrapper.text()).toContain('Boot Configuration'); + expect(wrapper.text()).toContain('USB/Flash Drive'); + }); + + it('hides boot configuration section when internal boot step was skipped', () => { + draftStore.bootMode = 'usb'; + draftStore.internalBootSelection = null; + draftStore.internalBootInitialized = true; + draftStore.internalBootSkipped = true; + + const { wrapper } = mountComponent(); + + expect(wrapper.text()).not.toContain('Boot Configuration'); + }); + + it('hides boot configuration section when internal boot step is not initialized', () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = null; + draftStore.internalBootInitialized = false; + draftStore.internalBootSkipped = false; + + const { wrapper } = mountComponent(); + + expect(wrapper.text()).not.toContain('Boot Configuration'); + }); + + it('shows selected boot devices with device name and size details', () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: 'boot', + slotCount: 2, + devices: ['diskA', 'diskB'], + bootSizeMiB: 16384, + updateBios: true, + }; + + const { wrapper } = mountComponent(); + + expect(wrapper.text()).toContain('diskA - 500 GB (sda)'); + expect(wrapper.text()).toContain('diskB - 250 GB (sdb)'); + }); + + it('requires confirmation before applying storage boot drive changes', async () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 1, + devices: ['diskA'], + bootSizeMiB: 16384, + updateBios: true, + }; + + const { wrapper } = mountComponent(); + const buttons = wrapper.findAll('[data-testid="brand-button"]'); + const applyButton = buttons[buttons.length - 1]; + await applyButton.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).toContain('Confirm Drive Wipe'); + expect(submitInternalBootCreationMock).not.toHaveBeenCalled(); + + const cancelButton = wrapper.findAll('button').find((button) => button.text().trim() === 'Cancel'); + expect(cancelButton).toBeTruthy(); + await cancelButton!.trigger('click'); + await flushPromises(); + + expect(wrapper.text()).not.toContain('Confirm Drive Wipe'); + expect(submitInternalBootCreationMock).not.toHaveBeenCalled(); + }); + + it('applies internal boot configuration without reboot and records success', async () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 2, + devices: ['diskA', 'diskB'], + bootSizeMiB: 16384, + updateBios: true, + }; + draftStore.internalBootSkipped = false; + submitInternalBootCreationMock.mockResolvedValue({ + ok: true, + output: [ + 'Applying BIOS boot entry updates...', + 'BIOS boot entry updates completed successfully.', + ].join('\n'), + }); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(submitInternalBootCreationMock).toHaveBeenCalledWith( + { + poolName: 'cache', + devices: ['diskA', 'diskB'], + bootSizeMiB: 16384, + updateBios: true, + }, + { reboot: false } + ); + expect(setInternalBootApplySucceededMock).toHaveBeenCalledWith(true); + expect(wrapper.text()).toContain('Internal boot pool configured.'); + expect(wrapper.text()).toContain('BIOS boot entry updates completed successfully.'); + expect(wrapper.text()).toContain('Setup Applied'); + expect(wrapper.text()).not.toContain('Setup Applied with Warnings'); + }); + + it('continues with warnings when internal boot setup returns an error', async () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 1, + devices: ['diskA'], + bootSizeMiB: 16384, + updateBios: false, + }; + submitInternalBootCreationMock.mockResolvedValue({ + ok: false, + output: 'mkbootpool failed', + }); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(setInternalBootApplySucceededMock).not.toHaveBeenCalledWith(true); + expect(wrapper.text()).toContain('Internal boot setup returned an error'); + expect(wrapper.text()).toContain('mkbootpool failed'); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }); + + it('surfaces BIOS update warnings in visible logs while keeping internal boot successful', async () => { + draftStore.bootMode = 'storage'; + draftStore.internalBootSelection = { + poolName: 'cache', + slotCount: 1, + devices: ['diskA'], + bootSizeMiB: 16384, + updateBios: true, + }; + submitInternalBootCreationMock.mockResolvedValue({ + ok: true, + output: [ + 'Applying BIOS boot entry updates...', + "efibootmgr failed for '/dev/sda' (rc=1)", + 'BIOS boot entry updates completed with warnings; manual BIOS boot order changes may still be required.', + ].join('\n'), + }); + + const { wrapper } = mountComponent(); + await clickApply(wrapper); + + expect(setInternalBootApplySucceededMock).toHaveBeenCalledWith(true); + expect(wrapper.text()).toContain( + 'BIOS boot entry updates completed with warnings; manual BIOS boot order changes may still be required.' + ); + expect(wrapper.text()).toContain("efibootmgr failed for '/dev/sda' (rc=1)"); + expect(wrapper.text()).toContain('Setup Applied with Warnings'); + }); }); diff --git a/web/__test__/components/Onboarding/internalBoot.test.ts b/web/__test__/components/Onboarding/internalBoot.test.ts new file mode 100644 index 0000000000..6061f59958 --- /dev/null +++ b/web/__test__/components/Onboarding/internalBoot.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + submitInternalBootCreation, + submitInternalBootReboot, +} from '~/components/Onboarding/composables/internalBoot'; + +const mutateMock = vi.fn(); + +vi.mock('@vue/apollo-composable', () => ({ + useApolloClient: () => ({ + client: { + mutate: mutateMock, + }, + }), +})); + +describe('internalBoot composable', () => { + beforeEach(() => { + vi.restoreAllMocks(); + mutateMock.mockReset(); + document.body.innerHTML = ''; + globalThis.csrf_token = 'csrf-token-value'; + }); + + it('submits create internal boot pool via onboarding mutation', async () => { + mutateMock.mockResolvedValue({ + data: { + onboarding: { + createInternalBootPool: { + ok: true, + code: 0, + output: 'done', + }, + }, + }, + }); + + const result = await submitInternalBootCreation({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }); + + expect(result).toEqual({ + ok: true, + code: 0, + output: 'done', + }); + expect(mutateMock).toHaveBeenCalledTimes(1); + const call = mutateMock.mock.calls[0]?.[0]; + expect(call).toBeDefined(); + if (!call || typeof call !== 'object') { + return; + } + + const payload = call as { + variables?: { + input?: { + poolName?: string; + devices?: string[]; + bootSizeMiB?: number; + updateBios?: boolean; + reboot?: boolean; + }; + }; + }; + + expect(payload.variables?.input).toEqual({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + reboot: false, + }); + }); + + it('returns fallback error when mutation response is empty', async () => { + mutateMock.mockResolvedValue({ + data: { + onboarding: { + createInternalBootPool: null, + }, + }, + }); + + const result = await submitInternalBootCreation({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }); + + expect(result.ok).toBe(false); + expect(result.output).toContain('Internal boot setup request failed'); + }); + + it('returns structured failure when mutation throws', async () => { + mutateMock.mockRejectedValue(new Error('network down')); + + const result = await submitInternalBootCreation({ + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: true, + }); + + expect(result.ok).toBe(false); + expect(result.output).toContain('Internal boot setup request failed'); + expect(result.output).toContain('network down'); + }); + + it('passes reboot flag when requested', async () => { + mutateMock.mockResolvedValue({ + data: { + onboarding: { + createInternalBootPool: { + ok: true, + code: 0, + output: 'done', + }, + }, + }, + }); + + await submitInternalBootCreation( + { + poolName: 'cache', + devices: ['disk-1'], + bootSizeMiB: 16384, + updateBios: false, + }, + { reboot: true } + ); + + const call = mutateMock.mock.calls[0]?.[0]; + expect(call).toBeDefined(); + if (!call || typeof call !== 'object') { + return; + } + + const payload = call as { + variables?: { + input?: { + reboot?: boolean; + }; + }; + }; + expect(payload.variables?.input?.reboot).toBe(true); + }); + + it('submits reboot form with cmd and csrf token', () => { + const submitSpy = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => undefined); + + submitInternalBootReboot(); + + expect(submitSpy).toHaveBeenCalledTimes(1); + const form = document.querySelector('form'); + expect(form).toBeTruthy(); + if (!form) { + return; + } + + expect(form.method.toLowerCase()).toBe('post'); + expect(form.target).toBe('_top'); + expect(form.getAttribute('action')).toBe('/plugins/dynamix/include/Boot.php'); + + const cmd = form.querySelector('input[name="cmd"]') as HTMLInputElement | null; + expect(cmd?.value).toBe('reboot'); + const csrf = form.querySelector('input[name="csrf_token"]') as HTMLInputElement | null; + expect(csrf?.value).toBe('csrf-token-value'); + }); +}); diff --git a/web/__test__/store/activationCodeModal.test.ts b/web/__test__/store/activationCodeModal.test.ts index 9baf27d25c..7d1d92d411 100644 --- a/web/__test__/store/activationCodeModal.test.ts +++ b/web/__test__/store/activationCodeModal.test.ts @@ -9,6 +9,7 @@ import type { App } from 'vue'; import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; import { useActivationCodeModalStore } from '~/components/Onboarding/store/activationCodeModal'; +import { useOnboardingStore } from '~/components/Onboarding/store/upgradeOnboarding.js'; import { useCallbackActionsStore } from '~/store/callbackActions'; import { useServerStore } from '~/store/server'; @@ -28,11 +29,16 @@ vi.mock('~/store/server', () => ({ useServerStore: vi.fn(), })); +vi.mock('~/components/Onboarding/store/upgradeOnboarding', () => ({ + useOnboardingStore: vi.fn(), +})); + describe('ActivationCodeModal Store', () => { let store: ReturnType; let mockIsHidden: ReturnType; let mockTemporaryBypassState: ReturnType; let mockIsFreshInstall: ReturnType; + let mockCompleted: ReturnType; let mockCallbackData: ReturnType; let mockUptime: ReturnType; let app: App | null = null; @@ -63,6 +69,7 @@ describe('ActivationCodeModal Store', () => { mockIsHidden = ref(null); mockTemporaryBypassState = ref(null); mockIsFreshInstall = ref(false); + mockCompleted = ref(false); mockCallbackData = ref(null); mockUptime = ref(3600); @@ -81,6 +88,10 @@ describe('ActivationCodeModal Store', () => { isFreshInstall: mockIsFreshInstall, } as unknown as ReturnType); + vi.mocked(useOnboardingStore).mockReturnValue({ + completed: mockCompleted, + } as unknown as ReturnType); + vi.mocked(useCallbackActionsStore).mockReturnValue({ callbackData: mockCallbackData, } as unknown as ReturnType); diff --git a/web/components.d.ts b/web/components.d.ts index 7f77c5583f..c45128297d 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -98,6 +98,7 @@ declare module 'vue' { 'OnboardingAdminPanel.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingAdminPanel.standalone.vue')['default'] OnboardingConsole: typeof import('./src/components/Onboarding/components/OnboardingConsole.vue')['default'] OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default'] + OnboardingInternalBootStep: typeof import('./src/components/Onboarding/steps/OnboardingInternalBootStep.vue')['default'] OnboardingLicenseStep: typeof import('./src/components/Onboarding/steps/OnboardingLicenseStep.vue')['default'] OnboardingModal: typeof import('./src/components/Onboarding/OnboardingModal.vue')['default'] OnboardingNextStepsStep: typeof import('./src/components/Onboarding/steps/OnboardingNextStepsStep.vue')['default'] diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 76bbaddef9..409e15abc7 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -2,14 +2,16 @@ import { computed, onMounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; import { storeToRefs } from 'pinia'; -import { useMutation } from '@vue/apollo-composable'; +import { useMutation, useQuery } from '@vue/apollo-composable'; import { ArrowTopRightOnSquareIcon, XMarkIcon } from '@heroicons/vue/24/solid'; import { Dialog } from '@unraid/ui'; import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation'; +import { GET_INTERNAL_BOOT_STEP_VISIBILITY_QUERY } from '@/components/Onboarding/graphql/getInternalBootStepVisibility.query'; import { DOCS_URL_ACCOUNT, DOCS_URL_LICENSING_FAQ } from '~/consts'; import type { BrandButtonProps } from '@unraid/ui'; +import type { StepId } from '~/components/Onboarding/stepRegistry'; import type { Component } from 'vue'; import OnboardingSteps from '~/components/Onboarding/OnboardingSteps.vue'; @@ -27,12 +29,17 @@ const { t } = useI18n(); const modalStore = useActivationCodeModalStore(); const { isVisible, isTemporarilyBypassed } = storeToRefs(modalStore); -const { activationRequired, hasActivationCode, registrationState } = storeToRefs( +const { activationRequired, hasActivationCode, isFreshInstall, registrationState } = storeToRefs( useActivationCodeDataStore() ); const onboardingStore = useUpgradeOnboardingStore(); -const { shouldShowOnboarding, isVersionDrift, completedAtVersion, canDisplayOnboardingModal } = - storeToRefs(onboardingStore); +const { + shouldShowOnboarding, + isVersionDrift, + completedAtVersion, + canDisplayOnboardingModal, + isPartnerBuild, +} = storeToRefs(onboardingStore); const { refetchOnboarding } = onboardingStore; const purchaseStore = usePurchaseStore(); const { keyfile } = storeToRefs(useServerStore()); @@ -57,19 +64,11 @@ const showKeyfileHint = computed(() => activationRequired.value && hasKeyfile.va const activateHref = computed(() => purchaseStore.generateUrl('activate')); const activateExternal = computed(() => purchaseStore.openInNewTab); -// Hardcoded step IDs matching the actual step flow -type StepId = - | 'OVERVIEW' - | 'CONFIGURE_SETTINGS' - | 'ADD_PLUGINS' - | 'ACTIVATE_LICENSE' - | 'SUMMARY' - | 'NEXT_STEPS'; - // Hardcoded step definitions - order matters for UI flow const HARDCODED_STEPS: Array<{ id: StepId; required: boolean }> = [ { id: 'OVERVIEW', required: false }, { id: 'CONFIGURE_SETTINGS', required: false }, + { id: 'CONFIGURE_BOOT', required: false }, { id: 'ADD_PLUGINS', required: false }, { id: 'ACTIVATE_LICENSE', required: true }, { id: 'SUMMARY', required: false }, @@ -82,21 +81,34 @@ const showActivationStep = computed(() => { return hasCode && ACTIVATION_STEP_REGISTRATION_STATES.has(regState); }); -// Determine which steps to show based on user state -const availableSteps = computed(() => { - if (showActivationStep.value) { - return HARDCODED_STEPS.map((s) => s.id); +const { result: internalBootVisibilityResult } = useQuery( + GET_INTERNAL_BOOT_STEP_VISIBILITY_QUERY, + null, + { + fetchPolicy: 'cache-first', } - return HARDCODED_STEPS.filter((s) => s.id !== 'ACTIVATE_LICENSE').map((s) => s.id); +); + +const showInternalBootStep = computed(() => { + const setting = internalBootVisibilityResult.value?.vars?.enableBootTransfer; + return typeof setting === 'string' && setting.trim().toLowerCase() === 'yes'; }); +// Determine which steps to show based on user state +const visibleHardcodedSteps = computed(() => + HARDCODED_STEPS.filter((step) => showActivationStep.value || step.id !== 'ACTIVATE_LICENSE').filter( + (step) => { + if (step.id !== 'CONFIGURE_BOOT') { + return true; + } + return !isPartnerBuild.value && showInternalBootStep.value; + } + ) +); +const availableSteps = computed(() => visibleHardcodedSteps.value.map((step) => step.id)); + // Filtered steps as full objects for OnboardingSteps component -const filteredSteps = computed(() => { - if (showActivationStep.value) { - return HARDCODED_STEPS; - } - return HARDCODED_STEPS.filter((s) => s.id !== 'ACTIVATE_LICENSE'); -}); +const filteredSteps = computed(() => visibleHardcodedSteps.value); const isLoginPage = computed(() => { const hasLoginRoute = window.location.pathname.includes('login'); @@ -106,6 +118,7 @@ const isLoginPage = computed(() => { const showModal = computed( () => !isLoginPage.value && + isFreshInstall.value && canDisplayOnboardingModal.value && !isTemporarilyBypassed.value && (isVisible.value || shouldShowOnboarding.value) @@ -243,6 +256,14 @@ const handlePluginsSkip = async () => { await goToNextStep(); }; +const handleInternalBootComplete = async () => { + await goToNextStep(); +}; + +const handleInternalBootSkip = async () => { + await goToNextStep(); +}; + const handleExitIntent = () => { showExitConfirmDialog.value = true; }; @@ -311,6 +332,16 @@ const currentStepProps = computed>(() => { }; } + case 'CONFIGURE_BOOT': { + const hardcodedStep = HARDCODED_STEPS.find((s) => s.id === 'CONFIGURE_BOOT'); + return { + ...baseProps, + onComplete: handleInternalBootComplete, + onSkip: hardcodedStep?.required ? undefined : handleInternalBootSkip, + showSkip: !hardcodedStep?.required, + }; + } + case 'ACTIVATE_LICENSE': return { ...baseProps, @@ -358,7 +389,7 @@ const currentStepProps = computed>(() => {
diff --git a/web/src/components/Onboarding/OnboardingSteps.vue b/web/src/components/Onboarding/OnboardingSteps.vue index 46a24bed27..f417e5eba0 100644 --- a/web/src/components/Onboarding/OnboardingSteps.vue +++ b/web/src/components/Onboarding/OnboardingSteps.vue @@ -2,19 +2,10 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'; import { useI18n } from 'vue-i18n'; -import type { StepMetadataEntry } from '~/components/Onboarding/stepRegistry'; +import type { StepId, StepMetadataEntry } from '~/components/Onboarding/stepRegistry'; import { stepMetadata } from '~/components/Onboarding/stepRegistry'; -// Hardcoded step type matching OnboardingModal -type StepId = - | 'OVERVIEW' - | 'CONFIGURE_SETTINGS' - | 'ADD_PLUGINS' - | 'ACTIVATE_LICENSE' - | 'SUMMARY' - | 'NEXT_STEPS'; - type HardcodedStep = { id: StepId; required: boolean }; const props = withDefaults( @@ -40,9 +31,11 @@ const { t } = useI18n(); t('onboarding.activationSteps.activateLicense'); t('onboarding.activationSteps.createAnUnraidNetAccountAnd'); t('onboarding.pluginsStep.addHelpfulPlugins'); +t('onboarding.internalBootStep.stepTitle'); +t('onboarding.internalBootStep.stepDescription'); const formatStep = (title: string, index: number, icon?: string): StepItem => ({ - title: `Step ${index + 1}`, + title: t('onboarding.stepper.stepLabel', { number: index + 1 }), description: title, icon, }); @@ -54,6 +47,7 @@ const dynamicSteps = computed(() => { const defaultSteps = [ metadataLookup.OVERVIEW, metadataLookup.CONFIGURE_SETTINGS, + metadataLookup.CONFIGURE_BOOT, metadataLookup.ADD_PLUGINS, metadataLookup.ACTIVATE_LICENSE, ]; diff --git a/web/src/components/Onboarding/components/OnboardingConsole.vue b/web/src/components/Onboarding/components/OnboardingConsole.vue index 66f4b9dff6..b6de268997 100644 --- a/web/src/components/Onboarding/components/OnboardingConsole.vue +++ b/web/src/components/Onboarding/components/OnboardingConsole.vue @@ -1,14 +1,20 @@