Skip to content

Commit d3032c1

Browse files
fix: unify onboarding internal boot state refresh (#1923)
## Summary - move onboarding internal boot reads to a backend-owned - refresh , , and on demand through - add a refresh action in onboarding so array start/stop and assignable drive state can be re-read without restarting the API ## Validation - > unraid-monorepo@4.29.2 lint /Users/elibosley/Code/api > pnpm -r lint Scope: 8 of 9 workspace projects unraid-ui lint$ eslint src web lint$ pnpm lint:eslint && pnpm lint:prettier web lint: > @unraid/web@4.29.2 lint:eslint /Users/elibosley/Code/api/web web lint: > eslint --cache web lint: > @unraid/web@4.29.2 lint:prettier /Users/elibosley/Code/api/web web lint: > prettier --check "**/*.{js,ts,tsx,vue}" web lint: Checking formatting... unraid-ui lint: Done web lint: All matched files use Prettier code style! web lint: Done api lint$ eslint --config .eslintrc.ts src/ api lint: Done - > @unraid/api@4.29.2 type-check /Users/elibosley/Code/api/api > tsc --noEmit - > @unraid/api@4.29.2 test /Users/elibosley/Code/api/api > NODE_ENV=test vitest run src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts RUN v3.2.4 /Users/elibosley/Code/api/api stdout | src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts [dotenv@17.2.1] injecting env (18) from .env.test -- tip: ⚙️ enable debug logging with { debug: true } stdout | src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts [dotenv@17.2.1] injecting env (18) from .env.test -- tip: ⚙️ write to custom object with { processEnv: myObject } stdout | src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts [dotenv@17.2.1] injecting env (18) from .env.test -- tip: ⚙️ specify custom .env file path with { path: '/custom/path/.env' } ✓ src/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.spec.ts (10 tests) 5ms ✓ src/unraid-api/graph/resolvers/onboarding/onboarding.query.spec.ts (1 test) 2ms ✓ src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts (8 tests) 4ms Test Files 3 passed (3) Tests 19 passed (19) Start at 12:25:35 Duration 1.37s (transform 278ms, setup 2.83s, collect 613ms, tests 11ms, environment 0ms, prepare 130ms) - > unraid-monorepo@4.29.2 type-check /Users/elibosley/Code/api > pnpm -r type-check Scope: 8 of 9 workspace projects unraid-ui type-check$ vue-tsc --noEmit web type-check$ vue-tsc --noEmit unraid-ui type-check: Done web type-check: Done api type-check$ tsc --noEmit api type-check: Done in - > unraid-monorepo@4.29.2 test /Users/elibosley/Code/api > pnpm -r test __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts Scope: 8 of 9 workspace projects packages/unraid-api-plugin-health test$ echo "Error: no test specified" && exit 0 __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts packages/unraid-shared test$ vitest run __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts plugin test$ vitest && pnpm run test:extractor && pnpm run test:shell-detection __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts unraid-ui test$ vitest __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts packages/unraid-api-plugin-health test: Error: no test specified packages/unraid-api-plugin-health test: sh: line 0: exit: too many arguments packages/unraid-api-plugin-health test: Failed /Users/elibosley/Code/api/packages/unraid-api-plugin-health:  ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL  unraid-api-plugin-health@4.25.3 test: `echo "Error: no test specified" && exit 0 __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts` Exit status 1 web test$ vitest run __test__/components/Onboarding/OnboardingInternalBootStep.test.ts __test__/components/Onboarding/OnboardingSummaryStep.test.ts  ELIFECYCLE  Test failed. See above for more details. in <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added manual refresh capability for internal boot context state with dedicated refresh button and loading indicators. * Simplified disk eligibility messaging to focus on size constraints only. * **Improvements** * Streamlined disk selection interface with updated data structure for assignable disks. * Enhanced internal boot onboarding context handling and state management. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ajit Mehrotra <ajit@lime-technology.com>
1 parent 5360b5b commit d3032c1

28 files changed

Lines changed: 904 additions & 463 deletions

api/generated-schema.graphql

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -346,11 +346,6 @@ type Disk implements Node {
346346
"""The serial number of the disk"""
347347
serialNum: String!
348348

349-
"""
350-
Device identifier from emhttp devs.ini used by disk assignment commands
351-
"""
352-
emhttpDeviceId: String
353-
354349
"""The interface type of the disk"""
355350
interfaceType: DiskInterfaceType!
356351

@@ -1080,6 +1075,18 @@ type OnboardingInternalBootResult {
10801075
output: String!
10811076
}
10821077

1078+
"""Current onboarding context for configuring internal boot"""
1079+
type OnboardingInternalBootContext {
1080+
arrayStopped: Boolean!
1081+
bootEligible: Boolean
1082+
bootedFromFlashWithInternalBootSetup: Boolean!
1083+
enableBootTransfer: String
1084+
reservedNames: [String!]!
1085+
shareNames: [String!]!
1086+
poolNames: [String!]!
1087+
assignableDisks: [Disk!]!
1088+
}
1089+
10831090
type RCloneDrive {
10841091
"""Provider name"""
10851092
name: String!
@@ -1394,6 +1401,11 @@ type OnboardingMutations {
13941401

13951402
"""Create and configure internal boot pool via emcmd operations"""
13961403
createInternalBootPool(input: CreateInternalBootPoolInput!): OnboardingInternalBootResult!
1404+
1405+
"""
1406+
Refresh the internal boot onboarding context from the latest emhttp state
1407+
"""
1408+
refreshInternalBootContext: OnboardingInternalBootContext!
13971409
}
13981410

13991411
"""Onboarding override input for testing"""
@@ -3162,6 +3174,9 @@ type Query {
31623174
notifications: Notifications!
31633175
online: Boolean!
31643176
owner: Owner!
3177+
3178+
"""Get the latest onboarding context for configuring internal boot"""
3179+
internalBootContext: OnboardingInternalBootContext!
31653180
registration: Registration
31663181
server: Server
31673182
servers: [Server!]!
@@ -3181,6 +3196,7 @@ type Query {
31813196
info: Info!
31823197
docker: Docker!
31833198
disks: [Disk!]!
3199+
assignableDisks: [Disk!]!
31843200
disk(id: PrefixedID!): Disk!
31853201
rclone: RCloneBackupSettings!
31863202
logFiles: [LogFile!]!
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
import { StateFileKey } from '@app/store/types.js';
4+
5+
type WatchHandler = (path: string) => Promise<void>;
6+
7+
const handlersByPath = new Map<string, Partial<Record<'add' | 'change', WatchHandler>>>();
8+
9+
const createWatcher = (path: string) => ({
10+
on: vi.fn((event: 'add' | 'change', handler: WatchHandler) => {
11+
const existingHandlers = handlersByPath.get(path) ?? {};
12+
existingHandlers[event] = handler;
13+
handlersByPath.set(path, existingHandlers);
14+
return createWatcher(path);
15+
}),
16+
});
17+
18+
const chokidarWatch = vi.fn((path: string) => createWatcher(path));
19+
20+
vi.mock('chokidar', () => ({
21+
watch: chokidarWatch,
22+
}));
23+
24+
vi.mock('@app/environment.js', () => ({
25+
CHOKIDAR_USEPOLLING: false,
26+
}));
27+
28+
vi.mock('@app/store/index.js', () => ({
29+
store: {
30+
dispatch: vi.fn(),
31+
},
32+
getters: {
33+
paths: vi.fn(() => ({
34+
states: '/usr/local/emhttp/state',
35+
})),
36+
},
37+
}));
38+
39+
vi.mock('@app/store/modules/emhttp.js', () => ({
40+
loadSingleStateFile: vi.fn((key) => ({ type: 'emhttp/load-single-state-file', payload: key })),
41+
}));
42+
43+
vi.mock('@app/core/log.js', () => ({
44+
emhttpLogger: {
45+
trace: vi.fn(),
46+
debug: vi.fn(),
47+
error: vi.fn(),
48+
},
49+
}));
50+
51+
describe('StateManager', () => {
52+
beforeEach(async () => {
53+
vi.resetModules();
54+
vi.clearAllMocks();
55+
handlersByPath.clear();
56+
57+
const { StateManager } = await import('@app/store/watch/state-watch.js');
58+
StateManager.instance = null;
59+
});
60+
61+
it('watches devs.ini alongside the other emhttp state files', async () => {
62+
const { StateManager } = await import('@app/store/watch/state-watch.js');
63+
64+
StateManager.getInstance();
65+
66+
expect(chokidarWatch).toHaveBeenCalledWith('/usr/local/emhttp/state/devs.ini', {
67+
usePolling: false,
68+
});
69+
});
70+
71+
it('reloads the devs state when devs.ini changes', async () => {
72+
const { StateManager } = await import('@app/store/watch/state-watch.js');
73+
const { store } = await import('@app/store/index.js');
74+
const { loadSingleStateFile } = await import('@app/store/modules/emhttp.js');
75+
76+
StateManager.getInstance();
77+
78+
const changeHandler = handlersByPath.get('/usr/local/emhttp/state/devs.ini')?.change;
79+
expect(changeHandler).toBeDefined();
80+
81+
await changeHandler?.('/usr/local/emhttp/state/devs.ini');
82+
83+
expect(store.dispatch).toHaveBeenCalledWith(loadSingleStateFile(StateFileKey.devs));
84+
});
85+
});

api/src/store/watch/state-watch.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,6 @@ import { getters, store } from '@app/store/index.js';
99
import { loadSingleStateFile } from '@app/store/modules/emhttp.js';
1010
import { StateFileKey } from '@app/store/types.js';
1111

12-
// Configure any excluded nchan channels that we support here
13-
const excludedWatches: StateFileKey[] = [StateFileKey.devs];
14-
1512
const chokidarOptionsForStateKey = (
1613
key: StateFileKey
1714
): Partial<Pick<FSWInstanceOptions, 'usePolling' | 'interval'>> => {
@@ -68,14 +65,12 @@ export class StateManager {
6865
private readonly setupChokidarWatchForState = () => {
6966
const { states } = getters.paths();
7067
for (const key of Object.values(StateFileKey)) {
71-
if (!excludedWatches.includes(key)) {
72-
const pathToWatch = join(states, `${key}.ini`);
73-
emhttpLogger.debug('Setting up watch for path: %s', pathToWatch);
74-
const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key));
75-
stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add'));
76-
stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change'));
77-
this.fileWatchers.push(stateWatch);
78-
}
68+
const pathToWatch = join(states, `${key}.ini`);
69+
emhttpLogger.debug('Setting up watch for path: %s', pathToWatch);
70+
const stateWatch = watch(pathToWatch, chokidarOptionsForStateKey(key));
71+
stateWatch.on('add', async (path) => this.handleStateFileUpdate(path, 'add'));
72+
stateWatch.on('change', async (path) => this.handleStateFileUpdate(path, 'change'));
73+
this.fileWatchers.push(stateWatch);
7974
}
8075
};
8176
}

api/src/unraid-api/cli/generated/graphql.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -714,8 +714,6 @@ export type Disk = Node & {
714714
bytesPerSector: Scalars['Float']['output'];
715715
/** The device path of the disk (e.g. /dev/sdb) */
716716
device: Scalars['String']['output'];
717-
/** Device identifier from emhttp devs.ini used by disk assignment commands */
718-
emhttpDeviceId?: Maybe<Scalars['String']['output']>;
719717
/** The firmware revision of the disk */
720718
firmwareRevision: Scalars['String']['output'];
721719
id: Scalars['PrefixedID']['output'];
@@ -1978,6 +1976,19 @@ export type Onboarding = {
19781976
status: OnboardingStatus;
19791977
};
19801978

1979+
/** Current onboarding context for configuring internal boot */
1980+
export type OnboardingInternalBootContext = {
1981+
__typename?: 'OnboardingInternalBootContext';
1982+
arrayStopped: Scalars['Boolean']['output'];
1983+
assignableDisks: Array<Disk>;
1984+
bootEligible?: Maybe<Scalars['Boolean']['output']>;
1985+
bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output'];
1986+
enableBootTransfer?: Maybe<Scalars['String']['output']>;
1987+
poolNames: Array<Scalars['String']['output']>;
1988+
reservedNames: Array<Scalars['String']['output']>;
1989+
shareNames: Array<Scalars['String']['output']>;
1990+
};
1991+
19811992
/** Result of attempting internal boot pool setup */
19821993
export type OnboardingInternalBootResult = {
19831994
__typename?: 'OnboardingInternalBootResult';
@@ -1995,6 +2006,8 @@ export type OnboardingMutations = {
19952006
completeOnboarding: Onboarding;
19962007
/** Create and configure internal boot pool via emcmd operations */
19972008
createInternalBootPool: OnboardingInternalBootResult;
2009+
/** Refresh the internal boot onboarding context from the latest emhttp state */
2010+
refreshInternalBootContext: OnboardingInternalBootContext;
19982011
/** Reset onboarding progress (for testing) */
19992012
resetOnboarding: Onboarding;
20002013
/** Override onboarding state for testing (in-memory only) */
@@ -2265,6 +2278,7 @@ export type Query = {
22652278
apiKeyPossibleRoles: Array<Role>;
22662279
apiKeys: Array<ApiKey>;
22672280
array: UnraidArray;
2281+
assignableDisks: Array<Disk>;
22682282
cloud: Cloud;
22692283
config: Config;
22702284
connect: Connect;
@@ -2283,6 +2297,8 @@ export type Query = {
22832297
info: Info;
22842298
/** List installed Unraid OS plugins by .plg filename */
22852299
installedUnraidPlugins: Array<Scalars['String']['output']>;
2300+
/** Get the latest onboarding context for configuring internal boot */
2301+
internalBootContext: OnboardingInternalBootContext;
22862302
/** Whether the system is a fresh install (no license key) */
22872303
isFreshInstall: Scalars['Boolean']['output'];
22882304
isSSOEnabled: Scalars['Boolean']['output'];
@@ -3209,6 +3225,7 @@ export type Vars = Node & {
32093225
__typename?: 'Vars';
32103226
bindMgt?: Maybe<Scalars['Boolean']['output']>;
32113227
bootEligible?: Maybe<Scalars['Boolean']['output']>;
3228+
bootedFromFlashWithInternalBootSetup?: Maybe<Scalars['Boolean']['output']>;
32123229
cacheNumDevices?: Maybe<Scalars['Int']['output']>;
32133230
cacheSbNumDisks?: Maybe<Scalars['Int']['output']>;
32143231
comment?: Maybe<Scalars['String']['output']>;

api/src/unraid-api/graph/resolvers/disks/disks.model.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,6 @@ export class Disk extends Node {
123123
@IsString()
124124
serialNum!: string;
125125

126-
@Field(() => String, {
127-
nullable: true,
128-
description: 'Device identifier from emhttp devs.ini used by disk assignment commands',
129-
})
130-
@IsOptional()
131-
@IsString()
132-
emhttpDeviceId?: string;
133-
134126
@Field(() => DiskInterfaceType, { description: 'The interface type of the disk' })
135127
@IsEnum(DiskInterfaceType)
136128
interfaceType!: DiskInterfaceType;

api/src/unraid-api/graph/resolvers/disks/disks.resolver.spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { DisksService } from '@app/unraid-api/graph/resolvers/disks/disks.servic
1414
// Mock the DisksService
1515
const mockDisksService = {
1616
getDisks: vi.fn(),
17+
getAssignableDisks: vi.fn(),
1718
getTemperature: vi.fn(),
1819
};
1920

@@ -88,6 +89,42 @@ describe('DisksResolver', () => {
8889
});
8990
});
9091

92+
describe('assignableDisks', () => {
93+
it('should return assignable disks from the service', async () => {
94+
const mockResult: Disk[] = [
95+
{
96+
id: 'SERIAL123',
97+
device: '/dev/sda',
98+
type: 'SSD',
99+
name: 'Samsung SSD 860 EVO 1TB',
100+
vendor: 'Samsung',
101+
size: 1000204886016,
102+
bytesPerSector: 512,
103+
totalCylinders: 121601,
104+
totalHeads: 255,
105+
totalSectors: 1953525168,
106+
totalTracks: 31008255,
107+
tracksPerCylinder: 255,
108+
sectorsPerTrack: 63,
109+
firmwareRevision: 'RVT04B6Q',
110+
serialNum: 'SERIAL123',
111+
interfaceType: DiskInterfaceType.SATA,
112+
smartStatus: DiskSmartStatus.OK,
113+
temperature: -1,
114+
partitions: [],
115+
isSpinning: false,
116+
},
117+
];
118+
mockDisksService.getAssignableDisks.mockResolvedValue(mockResult);
119+
120+
const result = await resolver.assignableDisks();
121+
122+
expect(result).toEqual(mockResult);
123+
expect(service.getAssignableDisks).toHaveBeenCalledTimes(1);
124+
expect(service.getAssignableDisks).toHaveBeenCalledWith();
125+
});
126+
});
127+
91128
describe('temperature', () => {
92129
it('should call getTemperature with the disk device', async () => {
93130
const mockDisk: Disk = {

api/src/unraid-api/graph/resolvers/disks/disks.resolver.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ export class DisksResolver {
2020
return this.disksService.getDisks();
2121
}
2222

23+
@Query(() => [Disk])
24+
@UsePermissions({
25+
action: AuthAction.READ_ANY,
26+
resource: Resource.DISK,
27+
})
28+
public async assignableDisks() {
29+
return this.disksService.getAssignableDisks();
30+
}
31+
2332
@Query(() => Disk)
2433
@UsePermissions({
2534
action: AuthAction.READ_ANY,

0 commit comments

Comments
 (0)