diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index d6dd77cc91..19f804c2d2 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -346,11 +346,6 @@ 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! @@ -1080,6 +1075,18 @@ type OnboardingInternalBootResult { output: String! } +"""Current onboarding context for configuring internal boot""" +type OnboardingInternalBootContext { + arrayStopped: Boolean! + bootEligible: Boolean + bootedFromFlashWithInternalBootSetup: Boolean! + enableBootTransfer: String + reservedNames: [String!]! + shareNames: [String!]! + poolNames: [String!]! + assignableDisks: [Disk!]! +} + type RCloneDrive { """Provider name""" name: String! @@ -1394,6 +1401,11 @@ type OnboardingMutations { """Create and configure internal boot pool via emcmd operations""" createInternalBootPool(input: CreateInternalBootPoolInput!): OnboardingInternalBootResult! + + """ + Refresh the internal boot onboarding context from the latest emhttp state + """ + refreshInternalBootContext: OnboardingInternalBootContext! } """Onboarding override input for testing""" @@ -3162,6 +3174,9 @@ type Query { notifications: Notifications! online: Boolean! owner: Owner! + + """Get the latest onboarding context for configuring internal boot""" + internalBootContext: OnboardingInternalBootContext! registration: Registration server: Server servers: [Server!]! @@ -3181,6 +3196,7 @@ type Query { info: Info! docker: Docker! disks: [Disk!]! + assignableDisks: [Disk!]! disk(id: PrefixedID!): Disk! rclone: RCloneBackupSettings! logFiles: [LogFile!]! diff --git a/api/src/__test__/store/watch/state-watch.test.ts b/api/src/__test__/store/watch/state-watch.test.ts new file mode 100644 index 0000000000..bea1286a57 --- /dev/null +++ b/api/src/__test__/store/watch/state-watch.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { StateFileKey } from '@app/store/types.js'; + +type WatchHandler = (path: string) => Promise; + +const handlersByPath = new Map>>(); + +const createWatcher = (path: string) => ({ + on: vi.fn((event: 'add' | 'change', handler: WatchHandler) => { + const existingHandlers = handlersByPath.get(path) ?? {}; + existingHandlers[event] = handler; + handlersByPath.set(path, existingHandlers); + return createWatcher(path); + }), +}); + +const chokidarWatch = vi.fn((path: string) => createWatcher(path)); + +vi.mock('chokidar', () => ({ + watch: chokidarWatch, +})); + +vi.mock('@app/environment.js', () => ({ + CHOKIDAR_USEPOLLING: false, +})); + +vi.mock('@app/store/index.js', () => ({ + store: { + dispatch: vi.fn(), + }, + getters: { + paths: vi.fn(() => ({ + states: '/usr/local/emhttp/state', + })), + }, +})); + +vi.mock('@app/store/modules/emhttp.js', () => ({ + loadSingleStateFile: vi.fn((key) => ({ type: 'emhttp/load-single-state-file', payload: key })), +})); + +vi.mock('@app/core/log.js', () => ({ + emhttpLogger: { + trace: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }, +})); + +describe('StateManager', () => { + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + handlersByPath.clear(); + + const { StateManager } = await import('@app/store/watch/state-watch.js'); + StateManager.instance = null; + }); + + it('watches devs.ini alongside the other emhttp state files', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + + StateManager.getInstance(); + + expect(chokidarWatch).toHaveBeenCalledWith('/usr/local/emhttp/state/devs.ini', { + usePolling: false, + }); + }); + + it('reloads the devs state when devs.ini changes', async () => { + const { StateManager } = await import('@app/store/watch/state-watch.js'); + const { store } = await import('@app/store/index.js'); + const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js'); + + StateManager.getInstance(); + + const changeHandler = handlersByPath.get('/usr/local/emhttp/state/devs.ini')?.change; + expect(changeHandler).toBeDefined(); + + await changeHandler?.('/usr/local/emhttp/state/devs.ini'); + + expect(store.dispatch).toHaveBeenCalledWith(loadSingleStateFile(StateFileKey.devs)); + }); +}); diff --git a/api/src/store/watch/state-watch.ts b/api/src/store/watch/state-watch.ts index 7272d60cfe..ccf96a3cea 100644 --- a/api/src/store/watch/state-watch.ts +++ b/api/src/store/watch/state-watch.ts @@ -9,9 +9,6 @@ import { getters, store } from '@app/store/index.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; -// Configure any excluded nchan channels that we support here -const excludedWatches: StateFileKey[] = [StateFileKey.devs]; - const chokidarOptionsForStateKey = ( key: StateFileKey ): Partial> => { @@ -68,14 +65,12 @@ export class StateManager { private readonly setupChokidarWatchForState = () => { const { states } = getters.paths(); for (const key of Object.values(StateFileKey)) { - if (!excludedWatches.includes(key)) { - const pathToWatch = join(states, `${key}.ini`); - emhttpLogger.debug('Setting up watch for path: %s', pathToWatch); - const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key)); - stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); - stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); - this.fileWatchers.push(stateWatch); - } + const pathToWatch = join(states, `${key}.ini`); + emhttpLogger.debug('Setting up watch for path: %s', pathToWatch); + const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key)); + stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add')); + stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change')); + this.fileWatchers.push(stateWatch); } }; } diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index a12769f4ec..fcd5985d81 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -714,8 +714,6 @@ 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']; @@ -1978,6 +1976,19 @@ export type Onboarding = { status: OnboardingStatus; }; +/** Current onboarding context for configuring internal boot */ +export type OnboardingInternalBootContext = { + __typename?: 'OnboardingInternalBootContext'; + arrayStopped: Scalars['Boolean']['output']; + assignableDisks: Array; + bootEligible?: Maybe; + bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output']; + enableBootTransfer?: Maybe; + poolNames: Array; + reservedNames: Array; + shareNames: Array; +}; + /** Result of attempting internal boot pool setup */ export type OnboardingInternalBootResult = { __typename?: 'OnboardingInternalBootResult'; @@ -1995,6 +2006,8 @@ export type OnboardingMutations = { completeOnboarding: Onboarding; /** Create and configure internal boot pool via emcmd operations */ createInternalBootPool: OnboardingInternalBootResult; + /** Refresh the internal boot onboarding context from the latest emhttp state */ + refreshInternalBootContext: OnboardingInternalBootContext; /** Reset onboarding progress (for testing) */ resetOnboarding: Onboarding; /** Override onboarding state for testing (in-memory only) */ @@ -2265,6 +2278,7 @@ export type Query = { apiKeyPossibleRoles: Array; apiKeys: Array; array: UnraidArray; + assignableDisks: Array; cloud: Cloud; config: Config; connect: Connect; @@ -2283,6 +2297,8 @@ export type Query = { info: Info; /** List installed Unraid OS plugins by .plg filename */ installedUnraidPlugins: Array; + /** Get the latest onboarding context for configuring internal boot */ + internalBootContext: OnboardingInternalBootContext; /** Whether the system is a fresh install (no license key) */ isFreshInstall: Scalars['Boolean']['output']; isSSOEnabled: Scalars['Boolean']['output']; @@ -3209,6 +3225,7 @@ export type Vars = Node & { __typename?: 'Vars'; bindMgt?: Maybe; bootEligible?: Maybe; + bootedFromFlashWithInternalBootSetup?: Maybe; cacheNumDevices?: Maybe; cacheSbNumDisks?: Maybe; comment?: 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 ee30bce0e6..0a7b276754 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.model.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.model.ts @@ -123,14 +123,6 @@ 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.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts index a6ad8d5617..4cabb45216 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts @@ -14,6 +14,7 @@ import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.servic // Mock the DisksService const mockDisksService = { getDisks: vi.fn(), + getAssignableDisks: vi.fn(), getTemperature: vi.fn(), }; @@ -88,6 +89,42 @@ describe('DisksResolver', () => { }); }); + describe('assignableDisks', () => { + it('should return assignable disks from the service', async () => { + const mockResult: Disk[] = [ + { + id: 'SERIAL123', + device: '/dev/sda', + type: 'SSD', + name: 'Samsung SSD 860 EVO 1TB', + vendor: 'Samsung', + size: 1000204886016, + bytesPerSector: 512, + totalCylinders: 121601, + totalHeads: 255, + totalSectors: 1953525168, + totalTracks: 31008255, + tracksPerCylinder: 255, + sectorsPerTrack: 63, + firmwareRevision: 'RVT04B6Q', + serialNum: 'SERIAL123', + interfaceType: DiskInterfaceType.SATA, + smartStatus: DiskSmartStatus.OK, + temperature: -1, + partitions: [], + isSpinning: false, + }, + ]; + mockDisksService.getAssignableDisks.mockResolvedValue(mockResult); + + const result = await resolver.assignableDisks(); + + expect(result).toEqual(mockResult); + expect(service.getAssignableDisks).toHaveBeenCalledTimes(1); + expect(service.getAssignableDisks).toHaveBeenCalledWith(); + }); + }); + describe('temperature', () => { it('should call getTemperature with the disk device', async () => { const mockDisk: Disk = { diff --git a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts index 8341e11383..2bec50a536 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts @@ -20,6 +20,15 @@ export class DisksResolver { return this.disksService.getDisks(); } + @Query(() => [Disk]) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DISK, + }) + public async assignableDisks() { + return this.disksService.getAssignableDisks(); + } + @Query(() => Disk) @UsePermissions({ action: AuthAction.READ_ANY, 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 b9bfb474ad..80209d04ce 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 @@ -377,7 +377,6 @@ 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); @@ -395,8 +394,6 @@ 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'); expect(spunDownDisk).toBeDefined(); @@ -485,7 +482,6 @@ 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 () => { @@ -531,6 +527,43 @@ describe('DisksService', () => { }); }); + describe('getAssignableDisks', () => { + it('should return only disks present in devs.ini', async () => { + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'store.emhttp.disks') { + return mockArrayDisks; + } + if (key === 'store.emhttp.devices') { + return [ + { id: 'DEVS-SERIAL-SDA', device: '/dev/sda' }, + { id: 'DEVS-SERIAL-SDD', device: '/dev/sdd' }, + ]; + } + return defaultValue; + }); + + const disks = await service.getAssignableDisks(); + + expect(disks.map((disk) => disk.device)).toEqual(['/dev/sda', '/dev/sdd']); + expect(disks.map((disk) => disk.serialNum)).toEqual(['DEVS-SERIAL-SDA', 'DEVS-SERIAL-SDD']); + expect(configService.get).toHaveBeenCalledWith('store.emhttp.devices', []); + }); + + it('should return an empty array when devs.ini is empty', async () => { + vi.mocked(configService.get).mockImplementation((key: string, defaultValue?: unknown) => { + if (key === 'store.emhttp.disks') { + return mockArrayDisks; + } + if (key === 'store.emhttp.devices') { + return []; + } + return defaultValue; + }); + + await expect(service.getAssignableDisks()).resolves.toEqual([]); + }); + }); + describe('getInternalBootDevices', () => { it('should return disks that match the Unraid internal boot partition layout', async () => { mockExeca.mockResolvedValue({ @@ -631,7 +664,6 @@ describe('DisksService', () => { sectorsPerTrack: 1, firmwareRevision: '1', serialNum: 'internal', - emhttpDeviceId: 'internal', interfaceType: DiskInterfaceType.PCIE, smartStatus: DiskSmartStatus.OK, partitions: [], @@ -653,7 +685,6 @@ describe('DisksService', () => { sectorsPerTrack: 1, firmwareRevision: '1', serialNum: 'usb', - emhttpDeviceId: 'usb', interfaceType: DiskInterfaceType.USB, smartStatus: DiskSmartStatus.OK, partitions: [], 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 5c7562c674..69df3a3ac2 100644 --- a/api/src/unraid-api/graph/resolvers/disks/disks.service.ts +++ b/api/src/unraid-api/graph/resolvers/disks/disks.service.ts @@ -45,11 +45,6 @@ const SmartDataSchema = z.object({ .optional() .nullable(), }); -interface EmhttpDevice { - id?: string; - device?: string; -} - interface EmhttpDeviceRecord { id?: unknown; device?: unknown; @@ -80,10 +75,10 @@ export class DisksService { constructor(private readonly configService: ConfigService) {} - private getEmhttpDevices(): EmhttpDevice[] { + private getEmhttpDeviceMap(): Map { const rawDevicesValue = this.configService.get('store.emhttp.devices', []); const rawDevices = Array.isArray(rawDevicesValue) ? rawDevicesValue : []; - const emhttpDevices: EmhttpDevice[] = []; + const devices = new Map(); for (const raw of rawDevices) { if (!raw || typeof raw !== 'object') { @@ -94,29 +89,14 @@ export class DisksService { const id = typeof record.id === 'string' ? record.id.trim() : ''; const device = typeof record.device === 'string' ? record.device.trim() : ''; - if (!id || !device) { + if (!device) { continue; } - emhttpDevices.push({ - id, - device: normalizeDeviceName(device), - }); + devices.set(normalizeDeviceName(device), id); } - 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); + return devices; } public async getTemperature(device: string): Promise { @@ -164,6 +144,30 @@ export class DisksService { return disk; } + public async getAssignableDisks(): Promise { + const assignableDevices = this.getEmhttpDeviceMap(); + + if (assignableDevices.size === 0) { + return []; + } + + const disks = await this.getDisks(); + return disks + .filter((disk) => assignableDevices.has(normalizeDeviceName(disk.device))) + .map((disk) => { + const emhttpId = assignableDevices.get(normalizeDeviceName(disk.device))?.trim(); + if (!emhttpId) { + return disk; + } + + return { + ...disk, + id: emhttpId, + serialNum: emhttpId, + }; + }); + } + public async getInternalBootDevices(): Promise { const [disks, deviceNames] = await Promise.all([ this.getDisks(), @@ -291,8 +295,7 @@ export class DisksService { private async parseDisk( disk: Systeminformation.DiskLayoutData, partitionsToParse: Systeminformation.BlockDevicesData[], - arrayDisks: ArrayDisk[], - emhttpDevices: EmhttpDevice[] + arrayDisks: ArrayDisk[] ): Promise> { const partitions = partitionsToParse // Only get partitions from this disk @@ -357,12 +360,9 @@ 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, @@ -380,9 +380,8 @@ 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, emhttpDevices) + this.parseDisk(disk, partitions, arrayDisks) ); 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 ee26ee8cad..35229b3ca8 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -2,7 +2,10 @@ 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 { + OnboardingInternalBootContext, + 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'; @@ -83,6 +86,11 @@ export class OnboardingMutations { description: 'Create and configure internal boot pool via emcmd operations', }) createInternalBootPool!: OnboardingInternalBootResult; + + @Field(() => OnboardingInternalBootContext, { + description: 'Refresh the internal boot onboarding context from the latest emhttp state', + }) + refreshInternalBootContext!: OnboardingInternalBootContext; } @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 index 4b834e55a2..1179c4c64e 100644 --- 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 @@ -4,8 +4,10 @@ import { execa } from 'execa'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { getShares } from '@app/core/utils/shares/get-shares.js'; import { getters } from '@app/store/index.js'; import { loadStateFileSync } from '@app/store/services/state-file-loader.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js'; import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; @@ -27,27 +29,136 @@ vi.mock('@app/store/services/state-file-loader.js', () => ({ loadStateFileSync: vi.fn(), })); +vi.mock('@app/core/utils/shares/get-shares.js', () => ({ + getShares: vi.fn(), +})); + describe('OnboardingInternalBootService', () => { const internalBootStateService = { getBootedFromFlashWithInternalBootSetup: vi.fn(), invalidateCachedInternalBootDeviceState: vi.fn(), }; + const disksService = { + getAssignableDisks: vi.fn(), + } satisfies Pick; beforeEach(() => { vi.clearAllMocks(); internalBootStateService.getBootedFromFlashWithInternalBootSetup.mockResolvedValue(false); internalBootStateService.invalidateCachedInternalBootDeviceState.mockResolvedValue(undefined); + disksService.getAssignableDisks.mockResolvedValue([]); vi.mocked(getters.emhttp).mockReturnValue({ + var: {}, devices: [], disks: [], } as unknown as ReturnType); + vi.mocked(getShares).mockImplementation(((type?: string) => { + if (type === 'users' || type === 'disks') { + return []; + } + + return { + users: [], + disks: [], + }; + }) as unknown as typeof getShares); }); const createService = () => new OnboardingInternalBootService( - internalBootStateService as unknown as InternalBootStateService + internalBootStateService as unknown as InternalBootStateService, + disksService as unknown as DisksService ); + it('builds internal boot context from the latest emhttp state', async () => { + const assignableDisks = [ + { + id: 'disk-1', + device: '/dev/sda', + serialNum: 'SERIAL-1', + size: 1, + interfaceType: 'SATA', + }, + ]; + disksService.getAssignableDisks.mockResolvedValue(assignableDisks); + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: 'STOPPED', + bootEligible: true, + enableBootTransfer: 'yes', + reservedNames: 'flash,cache', + }, + devices: [{ id: 'disk-1', device: 'sda' }], + disks: [ + { type: 'CACHE', name: 'cache' }, + { type: 'CACHE', name: 'nvme' }, + { type: 'DATA', name: 'disk1' }, + ], + } as unknown as ReturnType); + vi.mocked(getShares).mockImplementation(((scope?: string) => { + if (scope === 'users') { + return [{ name: 'media' }]; + } + if (scope === 'disks') { + return [{ name: 'diskshare' }]; + } + + return { + users: [{ name: 'media' }], + disks: [{ name: 'diskshare' }], + }; + }) as unknown as typeof getShares); + + const service = createService(); + const result = await service.getInternalBootContext(); + + expect(result).toEqual({ + arrayStopped: true, + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: ['flash', 'cache'], + shareNames: ['media', 'diskshare'], + poolNames: ['cache', 'nvme'], + assignableDisks, + }); + expect(vi.mocked(loadStateFileSync)).not.toHaveBeenCalled(); + }); + + it('refreshes internal boot context from disk and invalidates cached device state', async () => { + const assignableDisks = [ + { + id: 'disk-1', + device: '/dev/sda', + serialNum: 'SERIAL-1', + size: 1, + interfaceType: 'SATA', + }, + ]; + disksService.getAssignableDisks.mockResolvedValue(assignableDisks); + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: 'STOPPED', + bootEligible: true, + enableBootTransfer: 'yes', + reservedNames: '', + }, + devices: [{ id: 'disk-1', device: 'sda' }], + disks: [{ type: 'CACHE', name: 'cache' }], + } as unknown as ReturnType); + + const service = createService(); + const result = await service.refreshInternalBootContext(); + + expect(internalBootStateService.invalidateCachedInternalBootDeviceState).toHaveBeenCalledTimes( + 1 + ); + expect(vi.mocked(loadStateFileSync)).toHaveBeenNthCalledWith(1, 'var'); + expect(vi.mocked(loadStateFileSync)).toHaveBeenNthCalledWith(2, 'devs'); + expect(vi.mocked(loadStateFileSync)).toHaveBeenNthCalledWith(3, 'disks'); + expect(result.assignableDisks).toEqual(assignableDisks); + }); + it('runs the internal boot emcmd sequence and returns success', async () => { vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); const service = createService(); @@ -100,6 +211,7 @@ describe('OnboardingInternalBootService', () => { it('runs efibootmgr update flow when updateBios is requested', async () => { vi.mocked(emcmd).mockResolvedValue({ ok: true } as Awaited>); vi.mocked(getters.emhttp).mockReturnValue({ + var: { mdState: 'STOPPED' }, devices: [{ id: 'disk-1', device: 'sdb' }], disks: [{ type: 'FLASH', device: 'sda' }], } as unknown as ReturnType); 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 index 3746571d0e..ad7c573221 100644 --- 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 @@ -4,14 +4,17 @@ import { execa } from 'execa'; import type { CreateInternalBootPoolInput, + OnboardingInternalBootContext, 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 { getShares } from '@app/core/utils/shares/get-shares.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'; +import { ArrayDiskType, ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; +import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.service.js'; import { InternalBootStateService } from '@app/unraid-api/graph/resolvers/disks/internal-boot-state.service.js'; const INTERNAL_BOOT_COMMAND_TIMEOUT_MS = 180000; @@ -35,7 +38,10 @@ const isEmhttpDeviceRecord = (value: unknown): value is EmhttpDeviceRecord => { export class OnboardingInternalBootService { private readonly logger = new Logger(OnboardingInternalBootService.name); - constructor(private readonly internalBootStateService: InternalBootStateService) {} + constructor( + private readonly internalBootStateService: InternalBootStateService, + private readonly disksService: DisksService + ) {} private async isBootedFromFlashWithInternalBootSetup(): Promise { return this.internalBootStateService.getBootedFromFlashWithInternalBootSetup(); @@ -122,18 +128,100 @@ export class OnboardingInternalBootService { } } - private ensureEmhttpBootContext(): void { + private loadEmhttpBootContext(forceRefresh = false): void { const emhttpState = getters.emhttp(); + const hasVar = + typeof emhttpState.var === 'object' && + emhttpState.var !== null && + Object.keys(emhttpState.var).length > 0; const hasDevices = Array.isArray(emhttpState.devices) && emhttpState.devices.length > 0; const hasDisks = Array.isArray(emhttpState.disks) && emhttpState.disks.length > 0; - if (!hasDevices) { + if (forceRefresh || !hasVar) { + loadStateFileSync(StateFileKey.var); + } + if (forceRefresh || !hasDevices) { loadStateFileSync(StateFileKey.devs); } - if (!hasDisks) { + if (forceRefresh || !hasDisks) { loadStateFileSync(StateFileKey.disks); } } + private splitCsvValues(value: string | null | undefined): string[] { + if (typeof value !== 'string') { + return []; + } + + return value + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + + private getPoolNamesFromEmhttpState(): string[] { + const emhttpState = getters.emhttp(); + const names = new Set(); + + for (const disk of emhttpState.disks ?? []) { + const type = typeof disk.type === 'string' ? disk.type : ''; + const name = typeof disk.name === 'string' ? disk.name.trim() : ''; + if (type === ArrayDiskType.CACHE && name.length > 0) { + names.add(name); + } + } + + return Array.from(names); + } + + private getShareNames(): string[] { + const shareNames = new Set(); + for (const share of [...getShares('users'), ...getShares('disks')]) { + const name = typeof share.name === 'string' ? share.name.trim() : ''; + if (name.length > 0) { + shareNames.add(name); + } + } + return Array.from(shareNames); + } + + public async getInternalBootContext(): Promise { + this.loadEmhttpBootContext(); + + const vars = getters.emhttp().var ?? {}; + let bootedFromFlashWithInternalBootSetup = false; + + try { + bootedFromFlashWithInternalBootSetup = + await this.internalBootStateService.getBootedFromFlashWithInternalBootSetup(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to resolve internal boot context boot state: ${message}`); + } + + return { + arrayStopped: vars.mdState === ArrayState.STOPPED || vars.fsState === 'Stopped', + bootEligible: + typeof vars.bootEligible === 'boolean' + ? vars.bootEligible + : vars.bootEligible === null + ? null + : undefined, + bootedFromFlashWithInternalBootSetup, + enableBootTransfer: + typeof vars.enableBootTransfer === 'string' ? vars.enableBootTransfer : null, + reservedNames: this.splitCsvValues(vars.reservedNames), + shareNames: this.getShareNames(), + poolNames: this.getPoolNamesFromEmhttpState(), + assignableDisks: await this.disksService.getAssignableDisks(), + }; + } + + public async refreshInternalBootContext(): Promise { + this.loadEmhttpBootContext(true); + await this.internalBootStateService.invalidateCachedInternalBootDeviceState(); + return this.getInternalBootContext(); + } + private getDeviceMapFromEmhttpState(): Map { const emhttpState = getters.emhttp(); const rawDevices = Array.isArray(emhttpState.devices) ? emhttpState.devices : []; @@ -315,7 +403,7 @@ export class OnboardingInternalBootService { output: string[] ): Promise<{ hadFailures: boolean }> { let hadFailures = false; - this.ensureEmhttpBootContext(); + this.loadEmhttpBootContext(); const devsById = this.getDeviceMapFromEmhttpState(); const existingEntries = await this.runEfiBootMgr([], output); 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 f16f5534a8..c212298e71 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -16,6 +16,7 @@ import { ValidateNested, } from 'class-validator'; +import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; @InputType({ @@ -326,3 +327,32 @@ export class OnboardingInternalBootResult { @Field(() => String) output!: string; } + +@ObjectType({ + description: 'Current onboarding context for configuring internal boot', +}) +export class OnboardingInternalBootContext { + @Field(() => Boolean) + arrayStopped!: boolean; + + @Field(() => Boolean, { nullable: true }) + bootEligible?: boolean | null; + + @Field(() => Boolean) + bootedFromFlashWithInternalBootSetup!: boolean; + + @Field(() => String, { nullable: true }) + enableBootTransfer?: string | null; + + @Field(() => [String]) + reservedNames!: string[]; + + @Field(() => [String]) + shareNames!: string[]; + + @Field(() => [String]) + poolNames!: string[]; + + @Field(() => [Disk]) + assignableDisks!: Disk[]; +} 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 a1476d97cd..0f4e5a0c9b 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 @@ -29,7 +29,11 @@ describe('OnboardingMutationsResolver', () => { const onboardingInternalBootService = { createInternalBootPool: vi.fn(), - } satisfies Pick; + refreshInternalBootContext: vi.fn(), + } satisfies Pick< + OnboardingInternalBootService, + 'createInternalBootPool' | 'refreshInternalBootContext' + >; const defaultOnboardingResponse = { status: OnboardingStatus.INCOMPLETE, @@ -161,4 +165,29 @@ describe('OnboardingMutationsResolver', () => { }); expect(onboardingInternalBootService.createInternalBootPool).toHaveBeenCalledWith(input); }); + + it('delegates refreshInternalBootContext to onboarding internal boot service', async () => { + onboardingInternalBootService.refreshInternalBootContext.mockResolvedValue({ + arrayStopped: true, + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [], + }); + + await expect(resolver.refreshInternalBootContext()).resolves.toEqual({ + arrayStopped: true, + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [], + }); + expect(onboardingInternalBootService.refreshInternalBootContext).toHaveBeenCalledWith(); + }); }); 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 1011f65b2e..4a6804d0e3 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -11,6 +11,7 @@ import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mu import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; import { CreateInternalBootPoolInput, + OnboardingInternalBootContext, OnboardingInternalBootResult, OnboardingOverrideInput, } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; @@ -91,4 +92,15 @@ export class OnboardingMutationsResolver { ): Promise { return this.onboardingInternalBootService.createInternalBootPool(input); } + + @ResolveField(() => OnboardingInternalBootContext, { + description: 'Refresh onboarding internal boot context from the latest emhttp state', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async refreshInternalBootContext(): Promise { + return this.onboardingInternalBootService.refreshInternalBootContext(); + } } diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts new file mode 100644 index 0000000000..dbf18cb3f0 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; +import { OnboardingQueryResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.query.js'; + +describe('OnboardingQueryResolver', () => { + it('delegates internalBootContext to the onboarding internal boot service', async () => { + const onboardingInternalBootService = { + getInternalBootContext: vi.fn().mockResolvedValue({ + arrayStopped: true, + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [], + }), + } satisfies Pick; + + const resolver = new OnboardingQueryResolver( + onboardingInternalBootService as unknown as OnboardingInternalBootService + ); + + await expect(resolver.internalBootContext()).resolves.toEqual({ + arrayStopped: true, + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [], + }); + expect(onboardingInternalBootService.getInternalBootContext).toHaveBeenCalledWith(); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.ts new file mode 100644 index 0000000000..f7f591f361 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.query.ts @@ -0,0 +1,23 @@ +import { Query, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; +import { OnboardingInternalBootContext } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; + +@Resolver(() => OnboardingInternalBootContext) +export class OnboardingQueryResolver { + constructor(private readonly onboardingInternalBootService: OnboardingInternalBootService) {} + + @Query(() => OnboardingInternalBootContext, { + description: 'Get the latest onboarding context for configuring internal boot', + }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.WELCOME, + }) + async internalBootContext(): Promise { + return this.onboardingInternalBootService.getInternalBootContext(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 7001406539..f912fea200 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -22,6 +22,7 @@ import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notificatio 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 { OnboardingQueryResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.query.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'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; @@ -77,6 +78,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; OwnerResolver, OnboardingMutationsResolver, OnboardingInternalBootService, + OnboardingQueryResolver, RegistrationResolver, RootMutationsResolver, ServerResolver, diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts index bb8d7d8b5b..7cac35b2d4 100644 --- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts @@ -1,15 +1,12 @@ import { flushPromises, mount } from '@vue/test-utils'; +import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { GetInternalBootContextQuery } from '~/composables/gql/graphql'; import OnboardingInternalBootStep from '~/components/Onboarding/steps/OnboardingInternalBootStep.vue'; -import { - ArrayState, - DiskInterfaceType, - GetInternalBootContextDocument, -} from '~/composables/gql/graphql'; +import { DiskInterfaceType, GetInternalBootContextDocument } from '~/composables/gql/graphql'; import { createTestI18n } from '../../utils/i18n'; type MockInternalBootSelection = { @@ -20,7 +17,16 @@ type MockInternalBootSelection = { updateBios: boolean; }; -const { draftStore, contextResult, contextLoading, contextError, useQueryMock } = vi.hoisted(() => { +const { + draftStore, + contextResult, + contextLoading, + contextError, + refetchContextMock, + refreshContextMutationMock, + useQueryMock, + useMutationMock, +} = vi.hoisted(() => { const store = { bootMode: 'usb' as 'usb' | 'storage', internalBootSelection: null as MockInternalBootSelection | null, @@ -41,7 +47,10 @@ const { draftStore, contextResult, contextLoading, contextError, useQueryMock } contextResult: { value: null as GetInternalBootContextQuery | null, __v_isRef: true }, contextLoading: { value: false, __v_isRef: true }, contextError: { value: null as unknown, __v_isRef: true }, + refetchContextMock: vi.fn().mockResolvedValue(undefined), + refreshContextMutationMock: vi.fn().mockResolvedValue(undefined), useQueryMock: vi.fn(), + useMutationMock: vi.fn(), }; }); @@ -55,6 +64,7 @@ vi.mock('@unraid/ui', () => ({ })); vi.mock('@vue/apollo-composable', () => ({ + useMutation: useMutationMock, useQuery: useQueryMock, })); @@ -62,14 +72,42 @@ useQueryMock.mockImplementation(() => ({ result: contextResult, loading: contextLoading, error: contextError, + refetch: refetchContextMock, })); +useMutationMock.mockImplementation((document: unknown) => { + if (document === REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION) { + return { + mutate: refreshContextMutationMock, + }; + } + + return { + mutate: vi.fn(), + }; +}); + vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({ useOnboardingDraftStore: () => draftStore, })); const gib = (value: number) => value * 1024 * 1024 * 1024; +const buildContext = ( + overrides: Partial = {} +): GetInternalBootContextQuery => ({ + internalBootContext: { + bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [], + ...overrides, + }, +}); + const mountComponent = () => mount(OnboardingInternalBootStep, { props: { @@ -93,66 +131,55 @@ describe('OnboardingInternalBootStep', () => { it('renders all available server and disk eligibility codes when storage boot is blocked', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STARTED, - boot: { device: '/dev/sda' }, - parities: [{ device: '/dev/sdb' }], - disks: [{ device: '/dev/sdc' }], - caches: [{ name: 'cache', device: '/dev/sdd' }], - }, - vars: { - fsState: 'Started', - bootEligible: null, - enableBootTransfer: 'maybe', - reservedNames: '', - }, - shares: [], - disks: [ + contextResult.value = buildContext({ + bootEligible: null, + enableBootTransfer: 'maybe', + poolNames: ['cache'], + assignableDisks: [ { + id: 'BOOT-1', device: '/dev/sda', size: gib(32), serialNum: 'BOOT-1', - emhttpDeviceId: 'boot-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'PARITY-1', device: '/dev/sdb', size: gib(32), serialNum: 'PARITY-1', - emhttpDeviceId: 'parity-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'ARRAY-1', device: '/dev/sdc', size: gib(32), serialNum: 'ARRAY-1', - emhttpDeviceId: 'array-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'CACHE-1', device: '/dev/sdd', size: gib(32), serialNum: 'CACHE-1', - emhttpDeviceId: 'cache-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'SMALL-1', device: '/dev/sde', size: gib(6), serialNum: 'SMALL-1', - emhttpDeviceId: 'small-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'USB-1', device: '/dev/sdf', size: gib(32), serialNum: 'USB-1', - emhttpDeviceId: 'usb-disk', interfaceType: DiskInterfaceType.USB, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); @@ -160,16 +187,10 @@ describe('OnboardingInternalBootStep', () => { expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(true); expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true); expect(wrapper.text()).not.toContain('No eligible devices were detected for internal boot setup.'); - expect(wrapper.text()).not.toContain('ARRAY_NOT_STOPPED'); await wrapper.get('[data-testid="internal-boot-eligibility-toggle"]').trigger('click'); await flushPromises(); - expect(wrapper.text()).toContain('ARRAY_NOT_STOPPED'); expect(wrapper.text()).toContain('ENABLE_BOOT_TRANSFER_UNKNOWN'); expect(wrapper.text()).toContain('BOOT_ELIGIBLE_UNKNOWN'); - expect(wrapper.text()).toContain('ASSIGNED_TO_BOOT'); - expect(wrapper.text()).toContain('ASSIGNED_TO_PARITY'); - expect(wrapper.text()).toContain('ASSIGNED_TO_ARRAY'); - expect(wrapper.text()).toContain('ASSIGNED_TO_CACHE'); expect(wrapper.text()).toContain('TOO_SMALL'); expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS'); expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeDefined(); @@ -190,66 +211,38 @@ describe('OnboardingInternalBootStep', () => { it('shows drive serials in the selectable device labels', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [], - }, - vars: { - fsState: 'Stopped', - bootEligible: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ + contextResult.value = buildContext({ + assignableDisks: [ { + id: 'WD-TEST-1234', device: '/dev/sda', size: gib(32), serialNum: 'WD-TEST-1234', - emhttpDeviceId: 'eligible-disk', interfaceType: DiskInterfaceType.SATA, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); - expect(wrapper.text()).toContain('WD-TEST-1234 - 32.0 GB (sda)'); - expect(wrapper.text()).not.toContain('eligible-disk - 32.0 GB (sda)'); + expect(wrapper.text()).toContain('WD-TEST-1234 - 34.4 GB (sda)'); + expect(wrapper.text()).not.toContain('eligible-disk - 34.4 GB (sda)'); }); it('defaults the storage pool name to cache', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [], - }, - vars: { - fsState: 'Stopped', - bootEligible: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ + contextResult.value = buildContext({ + assignableDisks: [ { + id: 'ELIGIBLE-1', device: '/dev/sda', size: gib(32), serialNum: 'ELIGIBLE-1', - emhttpDeviceId: 'eligible-disk', interfaceType: DiskInterfaceType.SATA, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); @@ -263,31 +256,18 @@ describe('OnboardingInternalBootStep', () => { it('leaves the pool name blank when cache already exists', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [{ name: 'cache', device: '/dev/sdz' }], - }, - vars: { - fsState: 'Stopped', - bootEligible: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ + contextResult.value = buildContext({ + poolNames: ['cache'], + assignableDisks: [ { + id: 'ELIGIBLE-1', device: '/dev/sda', size: gib(32), serialNum: 'ELIGIBLE-1', - emhttpDeviceId: 'eligible-disk', interfaceType: DiskInterfaceType.SATA, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); @@ -297,38 +277,11 @@ describe('OnboardingInternalBootStep', () => { it('shows explicit disabled and empty-disk codes when the system reports them', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: { device: '/dev/sda' }, - parities: [{ device: '/dev/sdb' }], - disks: [], - caches: [], - }, - vars: { - fsState: 'Stopped', - bootEligible: false, - enableBootTransfer: 'no', - reservedNames: '', - }, - shares: [], - disks: [ - { - device: '/dev/sda', - size: gib(32), - serialNum: 'BOOT-1', - emhttpDeviceId: 'boot-disk', - interfaceType: DiskInterfaceType.SATA, - }, - { - device: '/dev/sdb', - size: gib(32), - serialNum: 'PARITY-1', - emhttpDeviceId: 'parity-disk', - interfaceType: DiskInterfaceType.SATA, - }, - ], - }; + contextResult.value = buildContext({ + bootEligible: false, + enableBootTransfer: 'no', + assignableDisks: [], + }); const wrapper = mountComponent(); await flushPromises(); @@ -342,32 +295,18 @@ describe('OnboardingInternalBootStep', () => { it('blocks configuration when internal boot is already configured while the system is still booted from flash', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [], - }, - vars: { - fsState: 'Stopped', - bootEligible: true, - bootedFromFlashWithInternalBootSetup: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ + contextResult.value = buildContext({ + bootedFromFlashWithInternalBootSetup: true, + assignableDisks: [ { + id: 'ELIGIBLE-1', device: '/dev/sda', size: gib(32), serialNum: 'ELIGIBLE-1', - emhttpDeviceId: 'eligible-disk', interfaceType: DiskInterfaceType.SATA, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); @@ -378,90 +317,34 @@ describe('OnboardingInternalBootStep', () => { expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeDefined(); }); - it('keeps the blocked headline focused on server state when eligible disks exist', async () => { - draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STARTED, - boot: null, - parities: [], - disks: [], - caches: [], - }, - vars: { - fsState: 'Started', - bootEligible: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ - { - device: '/dev/sda', - size: gib(32), - serialNum: 'ELIGIBLE-1', - emhttpDeviceId: 'eligible-disk', - interfaceType: DiskInterfaceType.SATA, - }, - ], - }; - - const wrapper = mountComponent(); - await flushPromises(); - - expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true); - expect(wrapper.text()).toContain('Storage boot is currently unavailable'); - expect(wrapper.text()).not.toContain('No eligible devices were detected for internal boot setup.'); - }); - it('shows disk-level ineligibility while keeping the form available for eligible disks', async () => { draftStore.bootMode = 'storage'; - contextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [{ name: 'cache', device: '/dev/sda' }], - }, - vars: { - fsState: 'Stopped', - bootEligible: true, - enableBootTransfer: 'yes', - reservedNames: '', - }, - shares: [], - disks: [ - { - device: '/dev/sda', - size: gib(32), - serialNum: 'CACHE-1', - emhttpDeviceId: 'cache-disk', - interfaceType: DiskInterfaceType.SATA, - }, + contextResult.value = buildContext({ + poolNames: ['cache'], + assignableDisks: [ { + id: 'SMALL-1', device: '/dev/sdb', size: gib(6), serialNum: 'SMALL-1', - emhttpDeviceId: 'small-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'ELIGIBLE-1', device: '/dev/sdc', size: gib(32), serialNum: 'ELIGIBLE-1', - emhttpDeviceId: 'eligible-disk', interfaceType: DiskInterfaceType.SATA, }, { + id: 'USB-1', device: '/dev/sdd', size: gib(32), serialNum: 'USB-1', - emhttpDeviceId: 'usb-disk', interfaceType: DiskInterfaceType.USB, }, ], - }; + }); const wrapper = mountComponent(); await flushPromises(); @@ -474,7 +357,6 @@ describe('OnboardingInternalBootStep', () => { expect(deviceSelect.text()).toContain('USB-1'); expect(deviceSelect.text()).not.toContain('CACHE-1'); expect(deviceSelect.text()).not.toContain('SMALL-1'); - expect(wrapper.text()).not.toContain('ASSIGNED_TO_CACHE'); const biosWarning = wrapper.get('[data-testid="internal-boot-update-bios-warning"]'); const eligibilityPanel = wrapper.get('[data-testid="internal-boot-eligibility-panel"]'); expect( @@ -483,8 +365,58 @@ describe('OnboardingInternalBootStep', () => { ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); await wrapper.get('[data-testid="internal-boot-eligibility-toggle"]').trigger('click'); await flushPromises(); - expect(wrapper.text()).toContain('ASSIGNED_TO_CACHE'); expect(wrapper.text()).toContain('TOO_SMALL'); expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined(); }); + + it('treats disks present in devs.ini as assignable', async () => { + draftStore.bootMode = 'storage'; + contextResult.value = buildContext({ + assignableDisks: [ + { + id: 'UNASSIGNED-1', + device: '/dev/sda', + size: gib(32), + serialNum: 'UNASSIGNED-1', + interfaceType: DiskInterfaceType.SATA, + }, + ], + }); + + const wrapper = mountComponent(); + await flushPromises(); + + expect(wrapper.find('[data-testid="internal-boot-intro-panel"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false); + const selects = wrapper.findAll('select'); + expect(selects).toHaveLength(3); + expect(selects[1]?.text()).toContain('UNASSIGNED-1'); + expect(wrapper.text()).not.toContain('ASSIGNED_TO_ARRAY'); + expect(wrapper.text()).not.toContain('NO_UNASSIGNED_DISKS'); + expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined(); + }); + + it('refreshes internal boot context on demand', async () => { + draftStore.bootMode = 'storage'; + contextResult.value = buildContext({ + assignableDisks: [ + { + id: 'UNASSIGNED-1', + device: '/dev/sda', + size: gib(32), + serialNum: 'UNASSIGNED-1', + interfaceType: DiskInterfaceType.SATA, + }, + ], + }); + + const wrapper = mountComponent(); + await flushPromises(); + + await wrapper.get('[data-testid="internal-boot-refresh-button"]').trigger('click'); + await flushPromises(); + + expect(refreshContextMutationMock).toHaveBeenCalledTimes(1); + expect(refetchContextMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index 893f45cebc..82d9b5d530 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -17,7 +17,6 @@ import type { GetInternalBootContextQuery } from '~/composables/gql/graphql'; import OnboardingSummaryStep from '~/components/Onboarding/steps/OnboardingSummaryStep.vue'; import { - ArrayState, DiskInterfaceType, GetInternalBootContextDocument, PluginInstallStatus, @@ -313,33 +312,30 @@ describe('OnboardingSummaryStep', () => { info: { primaryNetwork: { ipAddress: '192.168.1.2' } }, }; internalBootContextResult.value = { - array: { - state: ArrayState.STOPPED, - boot: null, - parities: [], - disks: [], - caches: [], - }, - vars: { + internalBootContext: { bootEligible: true, + bootedFromFlashWithInternalBootSetup: false, + enableBootTransfer: 'yes', + reservedNames: [], + shareNames: [], + poolNames: [], + assignableDisks: [ + { + id: 'DISK-A', + device: '/dev/sda', + size: 500 * 1024 * 1024 * 1024, + serialNum: 'DISK-A', + interfaceType: DiskInterfaceType.SATA, + }, + { + id: 'DISK-B', + device: '/dev/sdb', + size: 250 * 1024 * 1024 * 1024, + serialNum: 'DISK-B', + interfaceType: DiskInterfaceType.SATA, + }, + ], }, - shares: [], - disks: [ - { - device: '/dev/sda', - size: 500 * 1024 * 1024 * 1024, - serialNum: 'DISK-A', - emhttpDeviceId: 'diskA', - interfaceType: DiskInterfaceType.SATA, - }, - { - device: '/dev/sdb', - size: 250 * 1024 * 1024 * 1024, - serialNum: 'DISK-B', - emhttpDeviceId: 'diskB', - interfaceType: DiskInterfaceType.SATA, - }, - ], }; installedPluginsResult.value = { installedUnraidPlugins: [] }; availableLanguagesResult.value = { @@ -1109,15 +1105,15 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'boot', slotCount: 2, - devices: ['diskA', 'diskB'], + devices: ['DISK-A', 'DISK-B'], bootSizeMiB: 16384, updateBios: true, }; const { wrapper } = mountComponent(); - expect(wrapper.text()).toContain('DISK-A - 500 GB (sda)'); - expect(wrapper.text()).toContain('DISK-B - 250 GB (sdb)'); + expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)'); + expect(wrapper.text()).toContain('DISK-B - 268 GB (sdb)'); }); it('requires confirmation before applying storage boot drive changes', async () => { @@ -1125,7 +1121,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['diskA'], + devices: ['DISK-A'], bootSizeMiB: 16384, updateBios: true, }; @@ -1153,7 +1149,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 2, - devices: ['diskA', 'diskB'], + devices: ['DISK-A', 'DISK-B'], bootSizeMiB: 16384, updateBios: true, }; @@ -1172,7 +1168,7 @@ describe('OnboardingSummaryStep', () => { expect(submitInternalBootCreationMock).toHaveBeenCalledWith( { poolName: 'cache', - devices: ['diskA', 'diskB'], + devices: ['DISK-A', 'DISK-B'], bootSizeMiB: 16384, updateBios: true, }, @@ -1190,7 +1186,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['diskA'], + devices: ['DISK-A'], bootSizeMiB: 16384, updateBios: false, }; @@ -1213,7 +1209,7 @@ describe('OnboardingSummaryStep', () => { draftStore.internalBootSelection = { poolName: 'cache', slotCount: 1, - devices: ['diskA'], + devices: ['DISK-A'], bootSizeMiB: 16384, updateBios: true, }; diff --git a/web/package.json b/web/package.json index 533fb3c271..cecb1a9b07 100644 --- a/web/package.json +++ b/web/package.json @@ -39,8 +39,11 @@ "i18n:extract": "node ./scripts/extract-translations.mjs && pnpm run i18n:sort", "i18n:sort": "node ./scripts/sort-translations.mjs", "// Testing": "", + "pretest": "node ./scripts/build-ui-if-needed.js", "test": "vitest run", + "pretest:watch": "node ./scripts/build-ui-if-needed.js", "test:watch": "vitest", + "pretest:ci": "node ./scripts/build-ui-if-needed.js", "test:ci": "vitest run", "test:standalone": "pnpm run build && vite --config vite.test.config.ts" }, diff --git a/web/src/components/Onboarding/graphql/getInternalBootContext.query.ts b/web/src/components/Onboarding/graphql/getInternalBootContext.query.ts index 14cce60524..861c739942 100644 --- a/web/src/components/Onboarding/graphql/getInternalBootContext.query.ts +++ b/web/src/components/Onboarding/graphql/getInternalBootContext.query.ts @@ -2,38 +2,20 @@ import gql from 'graphql-tag'; export const GET_INTERNAL_BOOT_CONTEXT_QUERY = gql` query GetInternalBootContext { - array { - state - boot { - device - } - parities { - device - } - disks { - device - } - caches { - name - device - } - } - vars { - fsState + internalBootContext { bootEligible bootedFromFlashWithInternalBootSetup enableBootTransfer reservedNames - } - shares { - name - } - disks { - device - size - serialNum - emhttpDeviceId - interfaceType + shareNames + poolNames + assignableDisks { + id + device + size + serialNum + interfaceType + } } } `; diff --git a/web/src/components/Onboarding/graphql/refreshInternalBootContext.mutation.ts b/web/src/components/Onboarding/graphql/refreshInternalBootContext.mutation.ts new file mode 100644 index 0000000000..1311ade63c --- /dev/null +++ b/web/src/components/Onboarding/graphql/refreshInternalBootContext.mutation.ts @@ -0,0 +1,23 @@ +import { graphql } from '~/composables/gql'; + +export const REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION = graphql(/* GraphQL */ ` + mutation RefreshInternalBootContext { + onboarding { + refreshInternalBootContext { + bootEligible + bootedFromFlashWithInternalBootSetup + enableBootTransfer + reservedNames + shareNames + poolNames + assignableDisks { + id + device + size + serialNum + interfaceType + } + } + } + } +`); diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue index f7503717c8..9fcb55cccf 100644 --- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue @@ -1,13 +1,20 @@ @@ -811,6 +759,19 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions. {{ loadStatusMessage }} +
+ +
+