From a0b373f56e4b0f3b2397f815894008cd842948b4 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Thu, 12 Mar 2026 17:00:37 -0400 Subject: [PATCH 1/4] fix(onboarding): hide wizard when tracker state is unavailable - Purpose: prevent the onboarding wizard from appearing when the Unraid API is offline or the onboarding tracker cannot be read reliably. - Before: tracker read failures were treated like an empty tracker state, so the API could report onboarding as incomplete and the web modal could still auto-open. - Why that was a problem: users could be dropped into onboarding during degraded startup or API failure states, which made the flow noisy and misleading. - What changed: the tracker service now distinguishes a missing tracker file from a real read failure, the customization resolver surfaces tracker-read failure as an onboarding query error, and the web onboarding gate suppresses the modal while the query is loading or errored. - How it works: new tracker-health tests cover missing-vs-failed reads, resolver tests cover the error path, and a new onboarding status store test verifies the modal stays hidden for loading and error states. --- .../config/onboarding-tracker.service.spec.ts | 41 ++++++++ .../config/onboarding-tracker.service.ts | 19 ++++ .../customization.resolver.spec.ts | 11 +++ .../customization/customization.resolver.ts | 5 + web/__test__/store/onboardingStatus.test.ts | 99 +++++++++++++++++++ .../Onboarding/store/onboardingStatus.ts | 10 +- 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 web/__test__/store/onboardingStatus.test.ts diff --git a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts index 4a17584954..2f1dd80f08 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts @@ -87,3 +87,44 @@ describe('OnboardingTrackerService write retries', () => { expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3); }); }); + +describe('OnboardingTrackerService tracker state availability', () => { + beforeEach(() => { + mockReadFile.mockReset(); + mockAtomicWriteFile.mockReset(); + }); + + it('keeps tracker state available when the tracker file does not exist yet', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + expect(tracker.didTrackerStateReadFail()).toBe(false); + }); + + it('marks tracker state unavailable when reading the tracker file fails', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw new Error('permission denied'); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + expect(tracker.didTrackerStateReadFail()).toBe(true); + }); +}); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index a7fb9d785f..f68e86ee91 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -26,6 +26,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { private state: TrackerState = {}; private currentVersion?: string; private readonly versionFilePath: string; + private trackerStateReadFailed = false; constructor( private readonly configService: ConfigService, @@ -84,6 +85,10 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { return this.currentVersion; } + didTrackerStateReadFail(): boolean { + return this.trackerStateReadFailed; + } + /** * Mark onboarding as completed for the current OS version. */ @@ -167,8 +172,22 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { private async readTrackerState(): Promise { try { const content = await readFile(this.trackerPath, 'utf8'); + this.trackerStateReadFailed = false; return JSON.parse(content) as TrackerState; } catch (error) { + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + this.trackerStateReadFailed = false; + this.logger.debug( + `Onboarding tracker state does not exist yet at ${this.trackerPath}.` + ); + return undefined; + } + + this.trackerStateReadFailed = true; this.logger.debug( `Unable to read onboarding tracker state at ${this.trackerPath}: ${error}` ); diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts index a2faeddab1..22ac2afac2 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts @@ -16,6 +16,7 @@ describe('CustomizationResolver', () => { getOnboardingState: vi.fn(), } as unknown as OnboardingService; const onboardingTracker = { + didTrackerStateReadFail: vi.fn(), getState: vi.fn(), getCurrentVersion: vi.fn(), } as unknown as OnboardingTrackerService; @@ -27,6 +28,7 @@ describe('CustomizationResolver', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(false); vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.0'); vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue(null); vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue(null); @@ -62,6 +64,15 @@ describe('CustomizationResolver', () => { }); }); + it('throws when tracker state could not be read', async () => { + vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(true); + + await expect(resolver.resolveOnboarding()).rejects.toThrow( + 'Onboarding tracker state is unavailable.' + ); + expect(onboardingTracker.getState).not.toHaveBeenCalled(); + }); + it('returns COMPLETED status when completed on current version', async () => { vi.mocked(onboardingTracker.getState).mockReturnValue({ completed: true, diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index 1f4c770495..85aaca2474 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -1,4 +1,5 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; +import { GraphQLError } from 'graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; @@ -59,6 +60,10 @@ export class CustomizationResolver { resource: Resource.CUSTOMIZATIONS, }) async resolveOnboarding(): Promise { + if (this.onboardingTracker.didTrackerStateReadFail()) { + throw new GraphQLError('Onboarding tracker state is unavailable.'); + } + const state = this.onboardingTracker.getState(); const currentVersion = this.onboardingTracker.getCurrentVersion() ?? 'unknown'; const partnerInfo = await this.onboardingService.getPublicPartnerInfo(); diff --git a/web/__test__/store/onboardingStatus.test.ts b/web/__test__/store/onboardingStatus.test.ts new file mode 100644 index 0000000000..da4c689231 --- /dev/null +++ b/web/__test__/store/onboardingStatus.test.ts @@ -0,0 +1,99 @@ +import { ref } from 'vue'; +import { createPinia, setActivePinia } from 'pinia'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus'; +import { useServerStore } from '~/store/server'; + +type OnboardingQueryResult = { + customization?: { + onboarding?: { + status?: 'INCOMPLETE' | 'UPGRADE' | 'DOWNGRADE' | 'COMPLETED'; + isPartnerBuild?: boolean; + completed?: boolean; + completedAtVersion?: string | null; + }; + }; +}; + +const { state, refetchMock, useQueryMock } = vi.hoisted(() => ({ + state: { + onboardingResult: null as unknown as ReturnType>, + onboardingLoading: null as unknown as ReturnType>, + onboardingError: null as unknown as ReturnType>, + osVersionRef: null as unknown as ReturnType>, + }, + refetchMock: vi.fn(), + useQueryMock: vi.fn(), +})); + +const createOnboardingResult = (): OnboardingQueryResult => ({ + customization: { + onboarding: { + status: 'INCOMPLETE', + isPartnerBuild: false, + completed: false, + completedAtVersion: null, + }, + }, +}); + +describe('onboardingStatus store', () => { + beforeEach(() => { + vi.clearAllMocks(); + setActivePinia(createPinia()); + + state.onboardingResult = ref(createOnboardingResult()); + state.onboardingLoading = ref(false); + state.onboardingError = ref(null); + state.osVersionRef = ref('7.3.0'); + refetchMock.mockResolvedValue(undefined); + + useQueryMock.mockReturnValue({ + result: state.onboardingResult, + loading: state.onboardingLoading, + error: state.onboardingError, + refetch: refetchMock, + }); + + vi.mocked(useServerStore).mockReturnValue({ + osVersion: state.osVersionRef, + } as unknown as ReturnType); + }); + + it('blocks onboarding modal while the onboarding query is still loading', () => { + state.onboardingLoading.value = true; + + const store = useOnboardingStore(); + + expect(store.canDisplayOnboardingModal).toBe(false); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('blocks onboarding modal when the onboarding query errors', () => { + state.onboardingError.value = new Error('Network error'); + + const store = useOnboardingStore(); + + expect(store.hasOnboardingQueryError).toBe(true); + expect(store.canDisplayOnboardingModal).toBe(false); + expect(store.shouldShowOnboarding).toBe(false); + }); + + it('allows onboarding modal when the onboarding query succeeds', () => { + const store = useOnboardingStore(); + + expect(store.hasOnboardingQueryError).toBe(false); + expect(store.canDisplayOnboardingModal).toBe(true); + expect(store.shouldShowOnboarding).toBe(true); + }); +}); + +vi.mock('@vue/apollo-composable', () => ({ + useQuery: () => useQueryMock(), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: vi.fn(), +})); diff --git a/web/src/components/Onboarding/store/onboardingStatus.ts b/web/src/components/Onboarding/store/onboardingStatus.ts index 4a6797c414..3c7ea162cf 100644 --- a/web/src/components/Onboarding/store/onboardingStatus.ts +++ b/web/src/components/Onboarding/store/onboardingStatus.ts @@ -159,7 +159,14 @@ export const useOnboardingStore = defineStore('onboarding', () => { const isUnauthenticated = computed( () => mockUnauthenticated.value || isUnauthenticatedApolloError(onboardingError.value) ); - const canDisplayOnboardingModal = computed(() => isVersionSupported.value && !isUnauthenticated.value); + const hasOnboardingQueryError = computed(() => Boolean(onboardingError.value)); + const canDisplayOnboardingModal = computed( + () => + isVersionSupported.value && + !onboardingLoading.value && + !hasOnboardingQueryError.value && + !isUnauthenticated.value + ); // Automatic onboarding should only run for initial setup. const shouldShowOnboarding = computed(() => { @@ -189,6 +196,7 @@ export const useOnboardingStore = defineStore('onboarding', () => { mockUnauthenticated, mockOsVersion, isUnauthenticated, + hasOnboardingQueryError, canDisplayOnboardingModal, shouldShowOnboarding, // Actions From 69e372d2030623eb4c35e9a6582c286d77da3ed1 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 13 Mar 2026 11:28:32 -0400 Subject: [PATCH 2/4] chore(onboarding): apply lint and codegen follow-ups - Purpose: capture the follow-up formatting, generated artifacts, and install-script updates after the onboarding gating change. - Before: the branch had uncommitted lint-fix/codegen output and the plugin install script update was not yet recorded. - Why that was a problem: reviewers would not see the final generated API/web types or the latest plugin install behavior reflected in the branch history. - What changed: committed the lint-driven formatting updates in the onboarding files, regenerated GraphQL schema/types for API and web, and included the install script changes that add the corepack symlink and refresh npm/npx links. - How it works: this is a follow-up housekeeping commit only; it does not introduce a new onboarding behavior change beyond keeping generated artifacts and packaging scripts in sync with the branch state. --- api/generated-schema.graphql | 2 +- api/src/unraid-api/cli/generated/graphql.ts | 5 ++++- api/src/unraid-api/cli/generated/index.ts | 4 ++-- .../unraid-api/config/onboarding-tracker.service.ts | 10 ++-------- .../resolvers/customization/customization.resolver.ts | 2 +- plugin/source/dynamix.unraid.net/install/doinst.sh | 6 ++++++ web/src/composables/gql/graphql.ts | 2 +- web/src/composables/gql/index.ts | 2 +- 8 files changed, 18 insertions(+), 15 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 2f51f85b5b..54fc8a34e9 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -3567,4 +3567,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index e95c361499..1f03d6b58f 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -414,6 +414,8 @@ export type BrandingConfig = { background?: Maybe; /** Banner image source. Supports local path, remote URL, or data URI/base64. */ bannerImage?: Maybe; + /** Built-in case model value written to case-model.cfg when no custom override is supplied. */ + caseModel?: Maybe; /** Case model image source. Supports local path, remote URL, or data URI/base64. */ caseModelImage?: Maybe; /** Indicates if a partner logo exists */ @@ -451,6 +453,7 @@ export type BrandingConfig = { export type BrandingConfigInput = { background?: InputMaybe; bannerImage?: InputMaybe; + caseModel?: InputMaybe; caseModelImage?: InputMaybe; hasPartnerLogo?: InputMaybe; header?: InputMaybe; @@ -3548,4 +3551,4 @@ export const GetSsoUsersDocument = {"kind":"Document","definitions":[{"kind":"Op export const SystemReportDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SystemReport"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"machineId"}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"manufacturer"}},{"kind":"Field","name":{"kind":"Name","value":"model"}},{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"sku"}},{"kind":"Field","name":{"kind":"Name","value":"serial"}},{"kind":"Field","name":{"kind":"Name","value":"uuid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"versions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"core"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraid"}},{"kind":"Field","name":{"kind":"Name","value":"kernel"}}]}},{"kind":"Field","name":{"kind":"Name","value":"packages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openssl"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"server"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; export const ConnectStatusDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectStatus"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dynamicRemoteAccess"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enabledType"}},{"kind":"Field","name":{"kind":"Name","value":"runningType"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]}}]} as unknown as DocumentNode; export const ServicesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"services"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"online"}},{"kind":"Field","name":{"kind":"Name","value":"uptime"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}},{"kind":"Field","name":{"kind":"Name","value":"version"}}]}}]}}]} as unknown as DocumentNode; -export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; +export const ValidateOidcSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ValidateOidcSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"token"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"validateOidcSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"token"},"value":{"kind":"Variable","name":{"kind":"Name","value":"token"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/api/src/unraid-api/cli/generated/index.ts b/api/src/unraid-api/cli/generated/index.ts index 873144cb2c..6cf863446e 100644 --- a/api/src/unraid-api/cli/generated/index.ts +++ b/api/src/unraid-api/cli/generated/index.ts @@ -1,2 +1,2 @@ -export * from './fragment-masking.js'; -export * from './gql.js'; +export * from "./fragment-masking.js"; +export * from "./gql.js"; \ No newline at end of file diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index f68e86ee91..f7f27597e1 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -175,15 +175,9 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { this.trackerStateReadFailed = false; return JSON.parse(content) as TrackerState; } catch (error) { - if ( - error instanceof Error && - 'code' in error && - error.code === 'ENOENT' - ) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { this.trackerStateReadFailed = false; - this.logger.debug( - `Onboarding tracker state does not exist yet at ${this.trackerPath}.` - ); + this.logger.debug(`Onboarding tracker state does not exist yet at ${this.trackerPath}.`); return undefined; } diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index 85aaca2474..4d90fb02f6 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -1,8 +1,8 @@ import { Query, ResolveField, Resolver } from '@nestjs/graphql'; -import { GraphQLError } from 'graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; +import { GraphQLError } from 'graphql'; import { Public } from '@app/unraid-api/auth/public.decorator.js'; import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-tracker.module.js'; diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index 710522e626..49d650686e 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -37,3 +37,9 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) ( cd usr/local/bin ; rm -rf npx ) ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) +( cd usr/local/bin ; rm -rf corepack ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack ) +( cd usr/local/bin ; rm -rf npm ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) +( cd usr/local/bin ; rm -rf npx ) +( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 782ba84823..5aaf56c004 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -4115,4 +4115,4 @@ export const SignOutDocument = {"kind":"Document","definitions":[{"kind":"Operat export const IsSsoEnabledDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"IsSSOEnabled"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isSSOEnabled"}}]}}]} as unknown as DocumentNode; export const CloudStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"cloudState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PartialCloud"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; export const ServerStateDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"serverState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"config"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"valid"}}]}},{"kind":"Field","name":{"kind":"Name","value":"info"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"os"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hostname"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"owner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatar"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}},{"kind":"Field","name":{"kind":"Name","value":"registration"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"expiration"}},{"kind":"Field","name":{"kind":"Name","value":"keyFile"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"contents"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updateExpiration"}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regGen"}},{"kind":"Field","name":{"kind":"Name","value":"regState"}},{"kind":"Field","name":{"kind":"Name","value":"configError"}},{"kind":"Field","name":{"kind":"Name","value":"configValid"}}]}}]}}]} as unknown as DocumentNode; -export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; +export const GetThemeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"getTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicTheme"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"headerBackgroundColor"}},{"kind":"Field","name":{"kind":"Name","value":"showHeaderDescription"}},{"kind":"Field","name":{"kind":"Name","value":"headerPrimaryTextColor"}},{"kind":"Field","name":{"kind":"Name","value":"headerSecondaryTextColor"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/web/src/composables/gql/index.ts b/web/src/composables/gql/index.ts index 0ea4a91cf8..f51599168f 100644 --- a/web/src/composables/gql/index.ts +++ b/web/src/composables/gql/index.ts @@ -1,2 +1,2 @@ export * from "./fragment-masking"; -export * from "./gql"; +export * from "./gql"; \ No newline at end of file From 3307e4bcf4ae2e9a27de4f1a354f7d10b61cd477 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 13 Mar 2026 11:33:25 -0400 Subject: [PATCH 3/4] chore(onboarding): normalize generated barrel exports - Purpose: capture the final lint-driven cleanup after the onboarding follow-up commits. - Before: the generated API and web barrel files still had the previous codegen formatting and missing EOF newline. - Why that was a problem: the branch was left with a small staged diff after linting, which would keep the PR out of sync with the checked-in formatting. - What changed: committed the normalized export formatting in the generated barrel files for API and web. - How it works: this is a no-behavior-change cleanup commit that only aligns generated files with the latest lint output. --- api/src/unraid-api/cli/generated/index.ts | 4 ++-- web/src/composables/gql/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/api/src/unraid-api/cli/generated/index.ts b/api/src/unraid-api/cli/generated/index.ts index 6cf863446e..873144cb2c 100644 --- a/api/src/unraid-api/cli/generated/index.ts +++ b/api/src/unraid-api/cli/generated/index.ts @@ -1,2 +1,2 @@ -export * from "./fragment-masking.js"; -export * from "./gql.js"; \ No newline at end of file +export * from './fragment-masking.js'; +export * from './gql.js'; diff --git a/web/src/composables/gql/index.ts b/web/src/composables/gql/index.ts index f51599168f..0ea4a91cf8 100644 --- a/web/src/composables/gql/index.ts +++ b/web/src/composables/gql/index.ts @@ -1,2 +1,2 @@ export * from "./fragment-masking"; -export * from "./gql"; \ No newline at end of file +export * from "./gql"; From bdbd1718df40f0ab497c892fdfd40242163c884d Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 13 Mar 2026 12:06:07 -0400 Subject: [PATCH 4/4] fix(onboarding): address valid CodeRabbit feedback - Purpose: resolve the valid CodeRabbit comments on PR #1906 without taking unrelated or low-value suggestions. - Before: onboarding could remain blocked after a transient tracker read failure, one resolver spec used a brittle exact error-message assertion, the new onboarding status test had mock declarations below the test body, and doinst.sh contained duplicated symlink blocks. - Why that was a problem: the tracker-read gate could stay stale after recovery, the spec was more fragile than needed, the test file missed local mock-placement conventions, and the plugin install script had repeated generated commands that added noise and risked future confusion. - What changed: added a bounded tracker refresh before enforcing the onboarding gate, updated the resolver tests to cover refresh behavior and use .rejects.toThrow() without an exact message, moved the web test mocks to top-level, and collapsed the duplicated corepack/npm/npx symlink block to a single copy. - How it works: focused API vitest coverage confirms the retry path and guard behavior, focused web vitest coverage confirms the store/modal tests still pass, and bash -n confirms the trimmed install script still parses cleanly. --- .../config/onboarding-tracker.service.spec.ts | 32 +++++++++++++++++++ .../config/onboarding-tracker.service.ts | 15 +++++++++ .../customization.resolver.spec.ts | 22 +++++++++++-- .../customization/customization.resolver.ts | 5 ++- .../dynamix.unraid.net/install/doinst.sh | 12 ------- web/__test__/store/onboardingStatus.test.ts | 16 +++++----- 6 files changed, 78 insertions(+), 24 deletions(-) diff --git a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts index 2f1dd80f08..34351a4abd 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts @@ -127,4 +127,36 @@ describe('OnboardingTrackerService tracker state availability', () => { expect(tracker.didTrackerStateReadFail()).toBe(true); }); + + it('refreshes tracker state after a transient read failure', async () => { + const config = createConfigService(); + const overrides = new OnboardingOverrideService(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + throw new Error('permission denied'); + }); + + const tracker = new OnboardingTrackerService(config, overrides); + await tracker.onApplicationBootstrap(); + + mockReadFile.mockImplementation(async (filePath) => { + if (String(filePath).includes('unraid-version')) { + return 'version="7.2.0"\n'; + } + return JSON.stringify({ + completed: true, + completedAtVersion: '7.2.0', + }); + }); + + await expect(tracker.refreshTrackerStateIfNeeded()).resolves.toBe(true); + expect(tracker.didTrackerStateReadFail()).toBe(false); + expect(tracker.getState()).toEqual({ + completed: true, + completedAtVersion: '7.2.0', + }); + }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index f7f27597e1..1f754c3328 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -89,6 +89,21 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { return this.trackerStateReadFailed; } + async refreshTrackerStateIfNeeded(): Promise { + if (!this.trackerStateReadFailed) { + return true; + } + + const previousState = await this.readTrackerState(); + if (this.trackerStateReadFailed) { + return false; + } + + this.state = previousState ?? {}; + this.syncConfig(); + return true; + } + /** * Mark onboarding as completed for the current OS version. */ diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts index 22ac2afac2..326cfd4395 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts @@ -17,6 +17,7 @@ describe('CustomizationResolver', () => { } as unknown as OnboardingService; const onboardingTracker = { didTrackerStateReadFail: vi.fn(), + refreshTrackerStateIfNeeded: vi.fn(), getState: vi.fn(), getCurrentVersion: vi.fn(), } as unknown as OnboardingTrackerService; @@ -29,6 +30,7 @@ describe('CustomizationResolver', () => { beforeEach(() => { vi.clearAllMocks(); vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(false); + vi.mocked(onboardingTracker.refreshTrackerStateIfNeeded).mockResolvedValue(true); vi.mocked(onboardingTracker.getCurrentVersion).mockReturnValue('7.2.0'); vi.mocked(onboardingService.getPublicPartnerInfo).mockResolvedValue(null); vi.mocked(onboardingService.getActivationDataForPublic).mockResolvedValue(null); @@ -66,13 +68,27 @@ describe('CustomizationResolver', () => { it('throws when tracker state could not be read', async () => { vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(true); + vi.mocked(onboardingTracker.refreshTrackerStateIfNeeded).mockResolvedValue(false); - await expect(resolver.resolveOnboarding()).rejects.toThrow( - 'Onboarding tracker state is unavailable.' - ); + await expect(resolver.resolveOnboarding()).rejects.toThrow(); + expect(onboardingTracker.refreshTrackerStateIfNeeded).toHaveBeenCalledTimes(1); expect(onboardingTracker.getState).not.toHaveBeenCalled(); }); + it('retries tracker state before resolving onboarding', async () => { + vi.mocked(onboardingTracker.didTrackerStateReadFail).mockReturnValue(true); + vi.mocked(onboardingTracker.refreshTrackerStateIfNeeded).mockResolvedValue(true); + vi.mocked(onboardingTracker.getState).mockReturnValue({ + completed: false, + completedAtVersion: undefined, + }); + + const result = await resolver.resolveOnboarding(); + + expect(onboardingTracker.refreshTrackerStateIfNeeded).toHaveBeenCalledTimes(1); + expect(result.status).toBe(OnboardingStatus.INCOMPLETE); + }); + it('returns COMPLETED status when completed on current version', async () => { vi.mocked(onboardingTracker.getState).mockReturnValue({ completed: true, diff --git a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts index 4d90fb02f6..f7c35148c6 100644 --- a/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/customization/customization.resolver.ts @@ -60,7 +60,10 @@ export class CustomizationResolver { resource: Resource.CUSTOMIZATIONS, }) async resolveOnboarding(): Promise { - if (this.onboardingTracker.didTrackerStateReadFail()) { + if ( + this.onboardingTracker.didTrackerStateReadFail() && + !(await this.onboardingTracker.refreshTrackerStateIfNeeded()) + ) { throw new GraphQLError('Onboarding tracker state is unavailable.'); } diff --git a/plugin/source/dynamix.unraid.net/install/doinst.sh b/plugin/source/dynamix.unraid.net/install/doinst.sh index 49d650686e..e18f5f64eb 100644 --- a/plugin/source/dynamix.unraid.net/install/doinst.sh +++ b/plugin/source/dynamix.unraid.net/install/doinst.sh @@ -31,15 +31,3 @@ cp usr/local/unraid-api/.env.production usr/local/unraid-api/.env ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) ( cd usr/local/bin ; rm -rf npx ) ( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) -( cd usr/local/bin ; rm -rf corepack ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack ) -( cd usr/local/bin ; rm -rf npm ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) -( cd usr/local/bin ; rm -rf npx ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) -( cd usr/local/bin ; rm -rf corepack ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/corepack/dist/corepack.js corepack ) -( cd usr/local/bin ; rm -rf npm ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npm-cli.js npm ) -( cd usr/local/bin ; rm -rf npx ) -( cd usr/local/bin ; ln -sf ../lib/node_modules/npm/bin/npx-cli.js npx ) diff --git a/web/__test__/store/onboardingStatus.test.ts b/web/__test__/store/onboardingStatus.test.ts index da4c689231..cb96c958d7 100644 --- a/web/__test__/store/onboardingStatus.test.ts +++ b/web/__test__/store/onboardingStatus.test.ts @@ -6,6 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus'; import { useServerStore } from '~/store/server'; +vi.mock('@vue/apollo-composable', () => ({ + useQuery: () => useQueryMock(), +})); + +vi.mock('~/store/server', () => ({ + useServerStore: vi.fn(), +})); + type OnboardingQueryResult = { customization?: { onboarding?: { @@ -89,11 +97,3 @@ describe('onboardingStatus store', () => { expect(store.shouldShowOnboarding).toBe(true); }); }); - -vi.mock('@vue/apollo-composable', () => ({ - useQuery: () => useQueryMock(), -})); - -vi.mock('~/store/server', () => ({ - useServerStore: vi.fn(), -}));