From 28cfa41ec3e75b372c591bbdbedc884caaab4320 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Tue, 31 Mar 2026 17:26:45 -0400 Subject: [PATCH 01/52] feat(onboarding-api): move wizard persistence to the server - Purpose: make the API the source of truth for onboarding draft state, navigation state, and server-computed step visibility so resume works without browser persistence. - Before: the API only knew broad onboarding completion/open state, while the web owned draft progress and step flow in browser state. - Problem: refreshes and resumes depended on client storage, visible-step logic lived in the wrong layer, and there was no typed server contract for saving wizard progress. - Now: the API persists nested draft state in onboarding-tracker.json, computes visible steps on read, exposes a typed GraphQL contract for saving draft transitions, and logs save-failure close reasons. Files changed in this commit: - api/src/unraid-api/config/onboarding-tracker.model.ts: expanded the tracker model to include wizard step ids, nested draft sections, navigation state, and internal-boot operational state. - api/src/unraid-api/config/onboarding-tracker.service.ts: added normalization/defaulting for the new wizard shape, persisted draft/navigation/internalBootState, preserved non-draft tracker state during saves, and cleared wizard state on reset/completion. - api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts: added GraphQL enums and object types for the server-owned onboarding wizard payload returned to the web. - api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts: added live visible-step computation, current-step fallback resolution, wizard payload building, and mapping from GraphQL input to tracker format. - api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts: introduced typed GraphQL inputs for nested wizard draft saves plus close-onboarding reason metadata. - api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts: added saveOnboardingDraft and allowed closeOnboarding to accept a reason so save-failure exits can be logged. - api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts: surfaced saveOnboardingDraft on the onboarding mutations type. - api/generated-schema.graphql: regenerated schema shape to include the new wizard fields, enums, inputs, and mutation. - api/src/unraid-api/cli/generated/graphql.ts: regenerated API-side GraphQL types for the new wizard schema. - api/src/unraid-api/cli/generated/index.ts: refreshed generated exports to match the new GraphQL type output. --- api/generated-schema.graphql | 147 ++++++++- api/src/unraid-api/cli/generated/graphql.ts | 163 ++++++++++ .../config/onboarding-tracker.model.ts | 69 +++- .../config/onboarding-tracker.service.ts | 297 +++++++++++++++++- .../customization/activation-code.model.ts | 138 +++++++- .../customization/onboarding.service.ts | 203 ++++++++++++ .../resolvers/mutation/mutation.model.ts | 5 + .../resolvers/onboarding/onboarding.model.ts | 182 ++++++++++- .../onboarding/onboarding.mutation.ts | 24 +- 9 files changed, 1206 insertions(+), 22 deletions(-) diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 9fe342c7ba..6d418dfab2 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1027,6 +1027,75 @@ type OnboardingState { activationRequired: Boolean! } +type OnboardingWizardCoreSettingsDraft { + serverName: String + serverDescription: String + timeZone: String + theme: String + language: String + useSsh: Boolean +} + +type OnboardingWizardPluginsDraft { + selectedIds: [String!]! +} + +type OnboardingWizardInternalBootSelection { + poolName: String + slotCount: Int + devices: [String!]! + bootSizeMiB: Int + updateBios: Boolean + poolMode: OnboardingWizardPoolMode +} + +"""Pool mode selected for onboarding internal boot setup""" +enum OnboardingWizardPoolMode { + DEDICATED + HYBRID +} + +type OnboardingWizardInternalBootDraft { + bootMode: OnboardingWizardBootMode + skipped: Boolean + selection: OnboardingWizardInternalBootSelection +} + +"""Boot mode selected during onboarding""" +enum OnboardingWizardBootMode { + USB + STORAGE +} + +type OnboardingWizardDraft { + coreSettings: OnboardingWizardCoreSettingsDraft + plugins: OnboardingWizardPluginsDraft + internalBoot: OnboardingWizardInternalBootDraft +} + +type OnboardingWizardInternalBootState { + applyAttempted: Boolean! + applySucceeded: Boolean! +} + +type OnboardingWizard { + currentStepId: OnboardingWizardStepId + visibleStepIds: [OnboardingWizardStepId!]! + draft: OnboardingWizardDraft! + internalBootState: OnboardingWizardInternalBootState! +} + +"""Server-provided onboarding wizard step identifiers""" +enum OnboardingWizardStepId { + OVERVIEW + CONFIGURE_SETTINGS + CONFIGURE_BOOT + ADD_PLUGINS + ACTIVATE_LICENSE + SUMMARY + NEXT_STEPS +} + """Onboarding completion state and context""" type Onboarding { """ @@ -1051,6 +1120,9 @@ type Onboarding { """Runtime onboarding state values used by the onboarding flow""" onboardingState: OnboardingState! + + """Server-owned onboarding wizard state used by the web onboarding modal""" + wizard: OnboardingWizard! } """ @@ -1210,6 +1282,16 @@ type ArrayMutations { input ArrayStateInput { """Array state""" desiredState: ArrayStateInputState! + + """ + Optional password used to unlock encrypted array disks when starting the array + """ + decryptionPassword: String + + """ + Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. + """ + decryptionKeyfile: String } enum ArrayStateInputState { @@ -1408,7 +1490,7 @@ type OnboardingMutations { openOnboarding: Onboarding! """Close the onboarding modal""" - closeOnboarding: Onboarding! + closeOnboarding(input: CloseOnboardingInput): Onboarding! """Temporarily bypass onboarding in API memory""" bypassOnboarding: Onboarding! @@ -1422,6 +1504,9 @@ type OnboardingMutations { """Clear onboarding override state and reload from disk""" clearOnboardingOverride: Onboarding! + """Persist server-owned onboarding wizard draft state""" + saveOnboardingDraft(input: SaveOnboardingDraftInput!): Boolean! + """Create and configure internal boot pool via emcmd operations""" createInternalBootPool(input: CreateInternalBootPoolInput!): OnboardingInternalBootResult! @@ -1431,6 +1516,15 @@ type OnboardingMutations { refreshInternalBootContext: OnboardingInternalBootContext! } +input CloseOnboardingInput { + reason: CloseOnboardingReason +} + +"""Optional reason metadata for closing onboarding""" +enum CloseOnboardingReason { + SAVE_FAILURE +} + """Onboarding override input for testing""" input OnboardingOverrideInput { onboarding: OnboardingOverrideCompletionInput @@ -1505,6 +1599,55 @@ input PartnerInfoOverrideInput { branding: BrandingConfigInput } +input SaveOnboardingDraftInput { + draft: OnboardingWizardDraftInput + navigation: OnboardingWizardNavigationInput + internalBootState: OnboardingWizardInternalBootStateInput +} + +input OnboardingWizardDraftInput { + coreSettings: OnboardingWizardCoreSettingsDraftInput + plugins: OnboardingWizardPluginsDraftInput + internalBoot: OnboardingWizardInternalBootDraftInput +} + +input OnboardingWizardCoreSettingsDraftInput { + serverName: String + serverDescription: String + timeZone: String + theme: String + language: String + useSsh: Boolean +} + +input OnboardingWizardPluginsDraftInput { + selectedIds: [String!] +} + +input OnboardingWizardInternalBootDraftInput { + bootMode: OnboardingWizardBootMode + skipped: Boolean + selection: OnboardingWizardInternalBootSelectionInput +} + +input OnboardingWizardInternalBootSelectionInput { + poolName: String + slotCount: Int + devices: [String!] + bootSizeMiB: Int + updateBios: Boolean + poolMode: OnboardingWizardPoolMode +} + +input OnboardingWizardNavigationInput { + currentStepId: OnboardingWizardStepId +} + +input OnboardingWizardInternalBootStateInput { + applyAttempted: Boolean + applySucceeded: Boolean +} + """Input for creating an internal boot pool during onboarding""" input CreateInternalBootPoolInput { poolName: String! @@ -3609,4 +3752,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 0b0c497690..d2c040fe29 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -366,6 +366,10 @@ export enum ArrayState { } export type ArrayStateInput = { + /** Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. */ + decryptionKeyfile?: InputMaybe; + /** Optional password used to unlock encrypted array disks when starting the array */ + decryptionPassword?: InputMaybe; /** Array state */ desiredState: ArrayStateInputState; }; @@ -484,6 +488,15 @@ export type Capacity = { used: Scalars['String']['output']; }; +export type CloseOnboardingInput = { + reason?: InputMaybe; +}; + +/** Optional reason metadata for closing onboarding */ +export enum CloseOnboardingReason { + SAVE_FAILURE = 'SAVE_FAILURE' +} + export type Cloud = { __typename?: 'Cloud'; allowedOrigins: Array; @@ -1976,6 +1989,8 @@ export type Onboarding = { shouldOpen: Scalars['Boolean']['output']; /** The current onboarding status (INCOMPLETE, UPGRADE, DOWNGRADE, or COMPLETED) */ status: OnboardingStatus; + /** Server-owned onboarding wizard state used by the web onboarding modal */ + wizard: OnboardingWizard; }; /** Current onboarding context for configuring internal boot */ @@ -1985,12 +2000,21 @@ export type OnboardingInternalBootContext = { assignableDisks: Array; bootEligible?: Maybe; bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output']; + driveWarnings: Array; enableBootTransfer?: Maybe; poolNames: Array; reservedNames: Array; shareNames: Array; }; +/** Warning metadata for an assignable internal boot drive */ +export type OnboardingInternalBootDriveWarning = { + __typename?: 'OnboardingInternalBootDriveWarning'; + device: Scalars['String']['output']; + diskId: Scalars['String']['output']; + warnings: Array; +}; + /** Result of attempting internal boot pool setup */ export type OnboardingInternalBootResult = { __typename?: 'OnboardingInternalBootResult'; @@ -2020,17 +2044,31 @@ export type OnboardingMutations = { resetOnboarding: Onboarding; /** Clear the temporary onboarding bypass */ resumeOnboarding: Onboarding; + /** Persist server-owned onboarding wizard draft state */ + saveOnboardingDraft: Scalars['Boolean']['output']; /** Override onboarding state for testing (in-memory only) */ setOnboardingOverride: Onboarding; }; +/** Onboarding related mutations */ +export type OnboardingMutationsCloseOnboardingArgs = { + input?: InputMaybe; +}; + + /** Onboarding related mutations */ export type OnboardingMutationsCreateInternalBootPoolArgs = { input: CreateInternalBootPoolInput; }; +/** Onboarding related mutations */ +export type OnboardingMutationsSaveOnboardingDraftArgs = { + input: SaveOnboardingDraftInput; +}; + + /** Onboarding related mutations */ export type OnboardingMutationsSetOnboardingOverrideArgs = { input: OnboardingOverrideInput; @@ -2072,6 +2110,125 @@ export enum OnboardingStatus { UPGRADE = 'UPGRADE' } +export type OnboardingWizard = { + __typename?: 'OnboardingWizard'; + currentStepId?: Maybe; + draft: OnboardingWizardDraft; + internalBootState: OnboardingWizardInternalBootState; + visibleStepIds: Array; +}; + +/** Boot mode selected during onboarding */ +export enum OnboardingWizardBootMode { + STORAGE = 'STORAGE', + USB = 'USB' +} + +export type OnboardingWizardCoreSettingsDraft = { + __typename?: 'OnboardingWizardCoreSettingsDraft'; + language?: Maybe; + serverDescription?: Maybe; + serverName?: Maybe; + theme?: Maybe; + timeZone?: Maybe; + useSsh?: Maybe; +}; + +export type OnboardingWizardCoreSettingsDraftInput = { + language?: InputMaybe; + serverDescription?: InputMaybe; + serverName?: InputMaybe; + theme?: InputMaybe; + timeZone?: InputMaybe; + useSsh?: InputMaybe; +}; + +export type OnboardingWizardDraft = { + __typename?: 'OnboardingWizardDraft'; + coreSettings?: Maybe; + internalBoot?: Maybe; + plugins?: Maybe; +}; + +export type OnboardingWizardDraftInput = { + coreSettings?: InputMaybe; + internalBoot?: InputMaybe; + plugins?: InputMaybe; +}; + +export type OnboardingWizardInternalBootDraft = { + __typename?: 'OnboardingWizardInternalBootDraft'; + bootMode?: Maybe; + selection?: Maybe; + skipped?: Maybe; +}; + +export type OnboardingWizardInternalBootDraftInput = { + bootMode?: InputMaybe; + selection?: InputMaybe; + skipped?: InputMaybe; +}; + +export type OnboardingWizardInternalBootSelection = { + __typename?: 'OnboardingWizardInternalBootSelection'; + bootSizeMiB?: Maybe; + devices: Array; + poolMode?: Maybe; + poolName?: Maybe; + slotCount?: Maybe; + updateBios?: Maybe; +}; + +export type OnboardingWizardInternalBootSelectionInput = { + bootSizeMiB?: InputMaybe; + devices?: InputMaybe>; + poolMode?: InputMaybe; + poolName?: InputMaybe; + slotCount?: InputMaybe; + updateBios?: InputMaybe; +}; + +export type OnboardingWizardInternalBootState = { + __typename?: 'OnboardingWizardInternalBootState'; + applyAttempted: Scalars['Boolean']['output']; + applySucceeded: Scalars['Boolean']['output']; +}; + +export type OnboardingWizardInternalBootStateInput = { + applyAttempted?: InputMaybe; + applySucceeded?: InputMaybe; +}; + +export type OnboardingWizardNavigationInput = { + currentStepId?: InputMaybe; +}; + +export type OnboardingWizardPluginsDraft = { + __typename?: 'OnboardingWizardPluginsDraft'; + selectedIds: Array; +}; + +export type OnboardingWizardPluginsDraftInput = { + selectedIds?: InputMaybe>; +}; + +/** Pool mode selected for onboarding internal boot setup */ +export enum OnboardingWizardPoolMode { + DEDICATED = 'DEDICATED', + HYBRID = 'HYBRID' +} + +/** Server-provided onboarding wizard step identifiers */ +export enum OnboardingWizardStepId { + ACTIVATE_LICENSE = 'ACTIVATE_LICENSE', + ADD_PLUGINS = 'ADD_PLUGINS', + CONFIGURE_BOOT = 'CONFIGURE_BOOT', + CONFIGURE_SETTINGS = 'CONFIGURE_SETTINGS', + NEXT_STEPS = 'NEXT_STEPS', + OVERVIEW = 'OVERVIEW', + SUMMARY = 'SUMMARY' +} + export type Owner = { __typename?: 'Owner'; avatar: Scalars['String']['output']; @@ -2594,6 +2751,12 @@ export enum Role { VIEWER = 'VIEWER' } +export type SaveOnboardingDraftInput = { + draft?: InputMaybe; + internalBootState?: InputMaybe; + navigation?: InputMaybe; +}; + export type SensorConfig = { __typename?: 'SensorConfig'; enabled?: Maybe; diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts index 541f86d321..eba153ac48 100644 --- a/api/src/unraid-api/config/onboarding-tracker.model.ts +++ b/api/src/unraid-api/config/onboarding-tracker.model.ts @@ -1,6 +1,65 @@ +export const ONBOARDING_STEP_IDS = [ + 'OVERVIEW', + 'CONFIGURE_SETTINGS', + 'CONFIGURE_BOOT', + 'ADD_PLUGINS', + 'ACTIVATE_LICENSE', + 'SUMMARY', + 'NEXT_STEPS', +] as const; + +export type OnboardingStepId = (typeof ONBOARDING_STEP_IDS)[number]; + +export type OnboardingPoolMode = 'dedicated' | 'hybrid'; + +export type OnboardingBootMode = 'usb' | 'storage'; + +export type OnboardingInternalBootSelection = { + poolName?: string; + slotCount?: number; + devices?: string[]; + bootSizeMiB?: number; + updateBios?: boolean; + poolMode?: OnboardingPoolMode; +}; + +export type OnboardingCoreSettingsDraft = { + serverName?: string; + serverDescription?: string; + timeZone?: string; + theme?: string; + language?: string; + useSsh?: boolean; +}; + +export type OnboardingPluginsDraft = { + selectedIds?: string[]; +}; + +export type OnboardingInternalBootDraft = { + bootMode?: OnboardingBootMode; + skipped?: boolean; + selection?: OnboardingInternalBootSelection | null; +}; + +export type OnboardingDraft = { + coreSettings?: OnboardingCoreSettingsDraft; + plugins?: OnboardingPluginsDraft; + internalBoot?: OnboardingInternalBootDraft; +}; + +export type OnboardingNavigationState = { + currentStepId?: OnboardingStepId; +}; + +export type OnboardingInternalBootState = { + applyAttempted?: boolean; + applySucceeded?: boolean; +}; + /** - * Simplified onboarding tracker state. - * Tracks whether onboarding has been completed and at which version. + * Durable onboarding tracker state. + * Tracks onboarding completion plus the server-owned wizard draft. */ export type TrackerState = { /** Whether the onboarding flow has been completed */ @@ -9,4 +68,10 @@ export type TrackerState = { completedAtVersion?: string; /** Whether onboarding has been explicitly forced open */ forceOpen?: boolean; + /** Durable server-owned wizard draft */ + draft?: OnboardingDraft; + /** Durable navigation state for onboarding resume */ + navigation?: OnboardingNavigationState; + /** Operational internal boot state that is not user-entered draft data */ + internalBootState?: OnboardingInternalBootState; }; diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts index 4d005128f0..c6fbd3a40c 100644 --- a/api/src/unraid-api/config/onboarding-tracker.service.ts +++ b/api/src/unraid-api/config/onboarding-tracker.service.ts @@ -5,9 +5,22 @@ import path from 'path'; import { writeFile } from 'atomically'; -import type { TrackerState } from '@app/unraid-api/config/onboarding-tracker.model.js'; +import type { + OnboardingBootMode, + OnboardingCoreSettingsDraft, + OnboardingDraft, + OnboardingInternalBootDraft, + OnboardingInternalBootSelection, + OnboardingInternalBootState, + OnboardingNavigationState, + OnboardingPluginsDraft, + OnboardingPoolMode, + OnboardingStepId, + TrackerState, +} from '@app/unraid-api/config/onboarding-tracker.model.js'; import { PATHS_CONFIG_MODULES } from '@app/environment.js'; import { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js'; +import { ONBOARDING_STEP_IDS } from '@app/unraid-api/config/onboarding-tracker.model.js'; const TRACKER_FILE_NAME = 'onboarding-tracker.json'; const CONFIG_PREFIX = 'onboardingTracker'; @@ -15,13 +28,212 @@ const DEFAULT_OS_VERSION_FILE_PATH = '/etc/unraid-version'; const WRITE_RETRY_ATTEMPTS = 3; const WRITE_RETRY_DELAY_MS = 100; -type PublicTrackerState = { completed: boolean; completedAtVersion?: string; forceOpen: boolean }; +export type PublicTrackerState = { + completed: boolean; + completedAtVersion?: string; + forceOpen: boolean; + draft: OnboardingDraft; + navigation: OnboardingNavigationState; + internalBootState: OnboardingInternalBootState; +}; export type OnboardingTrackerStateResult = | { kind: 'ok'; state: PublicTrackerState } | { kind: 'missing'; state: PublicTrackerState } | { kind: 'error'; error: Error }; +type SaveOnboardingDraftInput = { + draft?: OnboardingDraft; + navigation?: OnboardingNavigationState; + internalBootState?: OnboardingInternalBootState; +}; + +const normalizeBoolean = (value: unknown, fallback = false): boolean => { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + const normalized = value.trim().toLowerCase(); + if (normalized === 'true') { + return true; + } + if (normalized === 'false') { + return false; + } + } + + return fallback; +}; + +const normalizeString = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : ''; +}; + +const normalizeStringArray = (value: unknown): string[] => { + if (!Array.isArray(value)) { + return []; + } + + return value.filter((item): item is string => typeof item === 'string'); +}; + +const normalizeStepId = (value: unknown): OnboardingStepId | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + return ONBOARDING_STEP_IDS.includes(value as OnboardingStepId) + ? (value as OnboardingStepId) + : undefined; +}; + +const normalizePoolMode = (value: unknown): OnboardingPoolMode | undefined => { + if (value === 'dedicated' || value === 'hybrid') { + return value; + } + + return undefined; +}; + +const normalizeBootMode = (value: unknown): OnboardingBootMode | undefined => { + if (value === 'usb' || value === 'storage') { + return value; + } + + return undefined; +}; + +const normalizeInternalBootSelection = (value: unknown): OnboardingInternalBootSelection | null => { + if (value === null) { + return null; + } + + if (!value || typeof value !== 'object') { + return null; + } + + const candidate = value as Record; + const parsedSlotCount = Number(candidate.slotCount); + const parsedBootSize = Number(candidate.bootSizeMiB); + + return { + poolName: normalizeString(candidate.poolName), + slotCount: Number.isFinite(parsedSlotCount) + ? Math.max(1, Math.min(2, parsedSlotCount)) + : undefined, + devices: normalizeStringArray(candidate.devices), + bootSizeMiB: Number.isFinite(parsedBootSize) ? Math.max(0, parsedBootSize) : undefined, + updateBios: normalizeBoolean(candidate.updateBios, false), + poolMode: normalizePoolMode(candidate.poolMode), + }; +}; + +const normalizeCoreSettingsDraft = (value: unknown): OnboardingCoreSettingsDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + serverName: normalizeString(candidate.serverName), + serverDescription: normalizeString(candidate.serverDescription), + timeZone: normalizeString(candidate.timeZone), + theme: normalizeString(candidate.theme), + language: normalizeString(candidate.language), + useSsh: typeof candidate.useSsh === 'boolean' ? candidate.useSsh : undefined, + }; +}; + +const normalizePluginsDraft = (value: unknown): OnboardingPluginsDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + selectedIds: normalizeStringArray(candidate.selectedIds), + }; +}; + +const normalizeInternalBootDraft = (value: unknown): OnboardingInternalBootDraft | undefined => { + if (!value || typeof value !== 'object') { + return undefined; + } + + const candidate = value as Record; + + return { + bootMode: normalizeBootMode(candidate.bootMode), + skipped: typeof candidate.skipped === 'boolean' ? candidate.skipped : undefined, + selection: + candidate.selection === undefined + ? undefined + : normalizeInternalBootSelection(candidate.selection), + }; +}; + +const normalizeDraft = (value: unknown): OnboardingDraft => { + if (!value || typeof value !== 'object') { + return {}; + } + + const candidate = value as Record; + + return { + coreSettings: normalizeCoreSettingsDraft(candidate.coreSettings), + plugins: normalizePluginsDraft(candidate.plugins), + internalBoot: normalizeInternalBootDraft(candidate.internalBoot), + }; +}; + +const normalizeNavigation = (value: unknown): OnboardingNavigationState => { + if (!value || typeof value !== 'object') { + return {}; + } + + const candidate = value as Record; + + return { + currentStepId: normalizeStepId(candidate.currentStepId), + }; +}; + +const normalizeInternalBootState = (value: unknown): OnboardingInternalBootState => { + if (!value || typeof value !== 'object') { + return { + applyAttempted: false, + applySucceeded: false, + }; + } + + const candidate = value as Record; + + return { + applyAttempted: normalizeBoolean(candidate.applyAttempted, false), + applySucceeded: normalizeBoolean(candidate.applySucceeded, false), + }; +}; + +const createEmptyPublicState = (): PublicTrackerState => ({ + completed: false, + completedAtVersion: undefined, + forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, +}); + /** * Simplified onboarding tracker service. * Tracks whether onboarding has been completed and at which version. @@ -53,21 +265,27 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { } private getCachedState(): PublicTrackerState { - // Check for override first (for testing) + const baseState = { + ...createEmptyPublicState(), + completed: this.state.completed ?? false, + completedAtVersion: this.state.completedAtVersion, + forceOpen: this.state.forceOpen ?? false, + draft: normalizeDraft(this.state.draft), + navigation: normalizeNavigation(this.state.navigation), + internalBootState: normalizeInternalBootState(this.state.internalBootState), + }; + const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { return { + ...baseState, completed: overrideState.onboarding.completed ?? false, completedAtVersion: overrideState.onboarding.completedAtVersion ?? undefined, forceOpen: overrideState.onboarding.forceOpen ?? false, }; } - return { - completed: this.state.completed ?? false, - completedAtVersion: this.state.completedAtVersion, - forceOpen: this.state.forceOpen ?? false, - }; + return baseState; } /** @@ -127,7 +345,6 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { */ async markCompleted(): Promise<{ completed: boolean; completedAtVersion?: string }> { this.bypassActive = false; - // Check for override first const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { const updatedOverride = { @@ -148,6 +365,12 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: true, completedAtVersion: this.currentVersion, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; await this.writeTrackerState(updatedState); @@ -161,7 +384,6 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { */ async reset(): Promise<{ completed: boolean; completedAtVersion?: string }> { this.bypassActive = false; - // Check for override first const overrideState = this.onboardingOverrides.getState(); if (overrideState?.onboarding !== undefined) { const updatedOverride = { @@ -181,6 +403,12 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: false, completedAtVersion: undefined, forceOpen: false, + draft: {}, + navigation: {}, + internalBootState: { + applyAttempted: false, + applySucceeded: false, + }, }; await this.writeTrackerState(updatedState); @@ -221,6 +449,9 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: currentStateResult.state.completed, completedAtVersion: currentStateResult.state.completedAtVersion, forceOpen, + draft: currentStateResult.state.draft, + navigation: currentStateResult.state.navigation, + internalBootState: currentStateResult.state.internalBootState, }; await this.writeTrackerState(updatedState); @@ -233,6 +464,43 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { this.bypassActive = active; } + async saveDraft(input: SaveOnboardingDraftInput): Promise { + const currentStateResult = await this.getStateResult(); + if (currentStateResult.kind === 'error') { + throw currentStateResult.error; + } + + const currentState = currentStateResult.state; + const nextDraft: OnboardingDraft = { + ...currentState.draft, + ...(input.draft ?? {}), + }; + + const updatedState: TrackerState = { + completed: currentState.completed, + completedAtVersion: currentState.completedAtVersion, + forceOpen: currentState.forceOpen, + draft: nextDraft, + navigation: input.navigation + ? { + ...currentState.navigation, + ...input.navigation, + } + : currentState.navigation, + internalBootState: input.internalBootState + ? { + ...currentState.internalBootState, + ...input.internalBootState, + } + : currentState.internalBootState, + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(); + + return this.getCachedState(); + } + private async readCurrentVersion(): Promise { try { const contents = await readFile(this.versionFilePath, 'utf8'); @@ -256,6 +524,9 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { completed: state.completed ?? false, completedAtVersion: state.completedAtVersion, forceOpen: state.forceOpen ?? false, + draft: normalizeDraft(state.draft), + navigation: normalizeNavigation(state.navigation), + internalBootState: normalizeInternalBootState(state.internalBootState), }, }; } catch (error) { @@ -263,11 +534,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap { this.logger.debug(`Onboarding tracker state does not exist yet at ${this.trackerPath}.`); return { kind: 'missing', - state: { - completed: false, - completedAtVersion: undefined, - forceOpen: false, - }, + state: createEmptyPublicState(), }; } diff --git a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts index 3e1747a4ed..e223c6fae9 100644 --- a/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts +++ b/api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; +import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator'; @@ -371,6 +371,137 @@ registerEnumType(OnboardingStatus, { description: 'The current onboarding status based on completion state and version relationship', }); +export enum OnboardingWizardStepId { + OVERVIEW = 'OVERVIEW', + CONFIGURE_SETTINGS = 'CONFIGURE_SETTINGS', + CONFIGURE_BOOT = 'CONFIGURE_BOOT', + ADD_PLUGINS = 'ADD_PLUGINS', + ACTIVATE_LICENSE = 'ACTIVATE_LICENSE', + SUMMARY = 'SUMMARY', + NEXT_STEPS = 'NEXT_STEPS', +} + +registerEnumType(OnboardingWizardStepId, { + name: 'OnboardingWizardStepId', + description: 'Server-provided onboarding wizard step identifiers', +}); + +export enum OnboardingWizardBootMode { + USB = 'usb', + STORAGE = 'storage', +} + +registerEnumType(OnboardingWizardBootMode, { + name: 'OnboardingWizardBootMode', + description: 'Boot mode selected during onboarding', +}); + +export enum OnboardingWizardPoolMode { + DEDICATED = 'dedicated', + HYBRID = 'hybrid', +} + +registerEnumType(OnboardingWizardPoolMode, { + name: 'OnboardingWizardPoolMode', + description: 'Pool mode selected for onboarding internal boot setup', +}); + +@ObjectType() +export class OnboardingWizardCoreSettingsDraft { + @Field(() => String, { nullable: true }) + serverName?: string; + + @Field(() => String, { nullable: true }) + serverDescription?: string; + + @Field(() => String, { nullable: true }) + timeZone?: string; + + @Field(() => String, { nullable: true }) + theme?: string; + + @Field(() => String, { nullable: true }) + language?: string; + + @Field(() => Boolean, { nullable: true }) + useSsh?: boolean; +} + +@ObjectType() +export class OnboardingWizardPluginsDraft { + @Field(() => [String]) + selectedIds!: string[]; +} + +@ObjectType() +export class OnboardingWizardInternalBootSelection { + @Field(() => String, { nullable: true }) + poolName?: string; + + @Field(() => Int, { nullable: true }) + slotCount?: number; + + @Field(() => [String]) + devices!: string[]; + + @Field(() => Int, { nullable: true }) + bootSizeMiB?: number; + + @Field(() => Boolean, { nullable: true }) + updateBios?: boolean; + + @Field(() => OnboardingWizardPoolMode, { nullable: true }) + poolMode?: OnboardingWizardPoolMode; +} + +@ObjectType() +export class OnboardingWizardInternalBootDraft { + @Field(() => OnboardingWizardBootMode, { nullable: true }) + bootMode?: OnboardingWizardBootMode; + + @Field(() => Boolean, { nullable: true }) + skipped?: boolean; + + @Field(() => OnboardingWizardInternalBootSelection, { nullable: true }) + selection?: OnboardingWizardInternalBootSelection | null; +} + +@ObjectType() +export class OnboardingWizardDraft { + @Field(() => OnboardingWizardCoreSettingsDraft, { nullable: true }) + coreSettings?: OnboardingWizardCoreSettingsDraft; + + @Field(() => OnboardingWizardPluginsDraft, { nullable: true }) + plugins?: OnboardingWizardPluginsDraft; + + @Field(() => OnboardingWizardInternalBootDraft, { nullable: true }) + internalBoot?: OnboardingWizardInternalBootDraft; +} + +@ObjectType() +export class OnboardingWizardInternalBootState { + @Field(() => Boolean) + applyAttempted!: boolean; + + @Field(() => Boolean) + applySucceeded!: boolean; +} + +@ObjectType() +export class OnboardingWizard { + @Field(() => OnboardingWizardStepId, { nullable: true }) + currentStepId?: OnboardingWizardStepId; + + @Field(() => [OnboardingWizardStepId]) + visibleStepIds!: OnboardingWizardStepId[]; + + @Field(() => OnboardingWizardDraft) + draft!: OnboardingWizardDraft; + + @Field(() => OnboardingWizardInternalBootState) + internalBootState!: OnboardingWizardInternalBootState; +} + @ObjectType({ description: 'Onboarding completion state and context', }) @@ -411,6 +542,11 @@ export class Onboarding { description: 'Runtime onboarding state values used by the onboarding flow', }) onboardingState!: OnboardingState; + + @Field(() => OnboardingWizard, { + description: 'Server-owned onboarding wizard state used by the web onboarding modal', + }) + wizard!: OnboardingWizard; } @ObjectType() diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index 1d08bea764..220d6994b7 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -9,6 +9,10 @@ import * as ini from 'ini'; import coerce from 'semver/functions/coerce.js'; import gte from 'semver/functions/gte.js'; +import type { + OnboardingDraft, + OnboardingStepId, +} from '@app/unraid-api/config/onboarding-tracker.model.js'; import { fileExists } from '@app/core/utils/files/file-exists.js'; import { safelySerializeObjectToIni } from '@app/core/utils/files/safe-ini-serializer.js'; import { loadDynamixConfigFromDiskSync } from '@app/store/actions/load-dynamix-config-file.js'; @@ -23,6 +27,10 @@ import { Onboarding, OnboardingState, OnboardingStatus, + OnboardingWizard, + OnboardingWizardBootMode, + OnboardingWizardPoolMode, + OnboardingWizardStepId, PublicPartnerInfo, } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { @@ -31,8 +39,46 @@ import { } from '@app/unraid-api/graph/resolvers/customization/activation-steps.util.js'; import { Theme, ThemeName } from '@app/unraid-api/graph/resolvers/customization/theme.model.js'; import { getOnboardingVersionDirection } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-status.util.js'; +import { SaveOnboardingDraftInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; const MIN_ONBOARDING_VERSION = '7.3.0'; +const WIZARD_STEP_ORDER: OnboardingWizardStepId[] = [ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + OnboardingWizardStepId.CONFIGURE_BOOT, + OnboardingWizardStepId.ADD_PLUGINS, + OnboardingWizardStepId.ACTIVATE_LICENSE, + OnboardingWizardStepId.SUMMARY, + OnboardingWizardStepId.NEXT_STEPS, +]; + +const toWizardBootMode = ( + value: 'usb' | 'storage' | undefined +): OnboardingWizardBootMode | undefined => { + if (value === 'usb') { + return OnboardingWizardBootMode.USB; + } + + if (value === 'storage') { + return OnboardingWizardBootMode.STORAGE; + } + + return undefined; +}; + +const toWizardPoolMode = ( + value: 'dedicated' | 'hybrid' | undefined +): OnboardingWizardPoolMode | undefined => { + if (value === 'dedicated') { + return OnboardingWizardPoolMode.DEDICATED; + } + + if (value === 'hybrid') { + return OnboardingWizardPoolMode.HYBRID; + } + + return undefined; +}; @Injectable() export class OnboardingService implements OnModuleInit { @@ -344,6 +390,115 @@ export class OnboardingService implements OnModuleInit { }; } + private shouldShowInternalBootStep(): boolean { + const enableBootTransfer = getters.emhttp().var?.enableBootTransfer; + return ( + typeof enableBootTransfer === 'string' && enableBootTransfer.trim().toLowerCase() === 'yes' + ); + } + + private getVisibleWizardStepIds(onboardingState: OnboardingState): OnboardingWizardStepId[] { + const visibleStepIds: OnboardingWizardStepId[] = [ + OnboardingWizardStepId.OVERVIEW, + OnboardingWizardStepId.CONFIGURE_SETTINGS, + ]; + + if (this.shouldShowInternalBootStep()) { + visibleStepIds.push(OnboardingWizardStepId.CONFIGURE_BOOT); + } + + visibleStepIds.push(OnboardingWizardStepId.ADD_PLUGINS); + + if (onboardingState.activationRequired) { + visibleStepIds.push(OnboardingWizardStepId.ACTIVATE_LICENSE); + } + + visibleStepIds.push(OnboardingWizardStepId.SUMMARY, OnboardingWizardStepId.NEXT_STEPS); + + return visibleStepIds; + } + + private resolveCurrentStepId( + currentStepId: OnboardingStepId | undefined, + visibleStepIds: OnboardingWizardStepId[] + ): OnboardingWizardStepId | undefined { + if (currentStepId && visibleStepIds.includes(currentStepId as OnboardingWizardStepId)) { + return currentStepId as OnboardingWizardStepId; + } + + if (!currentStepId) { + return visibleStepIds[0]; + } + + const currentOrderIndex = WIZARD_STEP_ORDER.indexOf(currentStepId as OnboardingWizardStepId); + if (currentOrderIndex < 0) { + return visibleStepIds[0]; + } + + for (let index = currentOrderIndex + 1; index < WIZARD_STEP_ORDER.length; index += 1) { + const nextStepId = WIZARD_STEP_ORDER[index]; + if (nextStepId && visibleStepIds.includes(nextStepId)) { + return nextStepId; + } + } + + for (let index = currentOrderIndex - 1; index >= 0; index -= 1) { + const previousStepId = WIZARD_STEP_ORDER[index]; + if (previousStepId && visibleStepIds.includes(previousStepId)) { + return previousStepId; + } + } + + return visibleStepIds[0]; + } + + private buildWizardState( + state: { + draft?: OnboardingDraft; + navigation?: { currentStepId?: OnboardingStepId }; + internalBootState?: { applyAttempted?: boolean; applySucceeded?: boolean }; + }, + onboardingState: OnboardingState + ): OnboardingWizard { + const visibleStepIds = this.getVisibleWizardStepIds(onboardingState); + const draft = state.draft ?? {}; + const navigation = state.navigation ?? {}; + const internalBootState = state.internalBootState ?? {}; + + return { + currentStepId: this.resolveCurrentStepId(navigation.currentStepId, visibleStepIds), + visibleStepIds, + draft: { + coreSettings: draft.coreSettings, + plugins: draft.plugins + ? { + selectedIds: draft.plugins.selectedIds ?? [], + } + : undefined, + internalBoot: draft.internalBoot + ? { + bootMode: toWizardBootMode(draft.internalBoot.bootMode), + skipped: draft.internalBoot.skipped, + selection: draft.internalBoot.selection + ? { + poolName: draft.internalBoot.selection.poolName, + slotCount: draft.internalBoot.selection.slotCount, + devices: draft.internalBoot.selection.devices ?? [], + bootSizeMiB: draft.internalBoot.selection.bootSizeMiB, + updateBios: draft.internalBoot.selection.updateBios, + poolMode: toWizardPoolMode(draft.internalBoot.selection.poolMode), + } + : (draft.internalBoot.selection ?? null), + } + : undefined, + }, + internalBootState: { + applyAttempted: internalBootState.applyAttempted ?? false, + applySucceeded: internalBootState.applySucceeded ?? false, + }, + }; + } + public async getOnboardingResponse(options?: { includeActivationCode?: boolean; }): Promise { @@ -383,6 +538,7 @@ export class OnboardingService implements OnModuleInit { activationCode, shouldOpen: !isBypassed && (isForceOpen || shouldAutoOpen), onboardingState, + wizard: this.buildWizardState(state, onboardingState), }; } @@ -426,6 +582,53 @@ export class OnboardingService implements OnModuleInit { this.onboardingTracker.setBypassActive(false); } + public async saveOnboardingDraft(input: SaveOnboardingDraftInput): Promise { + await this.onboardingTracker.saveDraft({ + draft: input.draft + ? { + coreSettings: input.draft.coreSettings, + plugins: input.draft.plugins + ? { + selectedIds: input.draft.plugins.selectedIds, + } + : undefined, + internalBoot: input.draft.internalBoot + ? { + bootMode: input.draft.internalBoot.bootMode, + skipped: input.draft.internalBoot.skipped, + selection: + input.draft.internalBoot.selection === undefined + ? undefined + : input.draft.internalBoot.selection + ? { + poolName: input.draft.internalBoot.selection.poolName, + slotCount: input.draft.internalBoot.selection.slotCount, + devices: input.draft.internalBoot.selection.devices, + bootSizeMiB: + input.draft.internalBoot.selection.bootSizeMiB, + updateBios: + input.draft.internalBoot.selection.updateBios, + poolMode: input.draft.internalBoot.selection.poolMode, + } + : null, + } + : undefined, + } + : undefined, + navigation: input.navigation + ? { + currentStepId: input.navigation.currentStepId, + } + : undefined, + internalBootState: input.internalBootState + ? { + applyAttempted: input.internalBootState.applyAttempted, + applySucceeded: input.internalBootState.applySucceeded, + } + : undefined, + }); + } + public isFreshInstall(): boolean { return this.onboardingState.isFreshInstall(); } 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 b56be8cc05..0069cf2a12 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -102,6 +102,11 @@ export class OnboardingMutations { }) clearOnboardingOverride!: Onboarding; + @Field(() => Boolean, { + description: 'Persist server-owned onboarding wizard draft state', + }) + saveOnboardingDraft!: boolean; + @Field(() => OnboardingInternalBootResult, { description: 'Create and configure internal boot pool via emcmd operations', }) 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 bd61811445..c80faca2b4 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -1,4 +1,4 @@ -import { Field, InputType, Int, ObjectType } from '@nestjs/graphql'; +import { Field, InputType, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; import { Type } from 'class-transformer'; import { @@ -16,9 +16,23 @@ import { ValidateNested, } from 'class-validator'; +import { + OnboardingWizardBootMode, + OnboardingWizardPoolMode, + OnboardingWizardStepId, +} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js'; import { Disk } from '@app/unraid-api/graph/resolvers/disks/disks.model.js'; import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js'; +export enum CloseOnboardingReason { + SAVE_FAILURE = 'SAVE_FAILURE', +} + +registerEnumType(CloseOnboardingReason, { + name: 'CloseOnboardingReason', + description: 'Optional reason metadata for closing onboarding', +}); + @InputType({ description: 'Onboarding completion override input', }) @@ -286,6 +300,172 @@ export class OnboardingOverrideInput { registrationState?: RegistrationState; } +@InputType() +export class OnboardingWizardCoreSettingsDraftInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + serverName?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + serverDescription?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + timeZone?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + theme?: string; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + language?: string; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + useSsh?: boolean; +} + +@InputType() +export class OnboardingWizardPluginsDraftInput { + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsString({ each: true }) + selectedIds?: string[]; +} + +@InputType() +export class OnboardingWizardInternalBootSelectionInput { + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + poolName?: string; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsInt() + @Min(1) + slotCount?: number; + + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsString({ each: true }) + devices?: string[]; + + @Field(() => Int, { nullable: true }) + @IsOptional() + @IsInt() + @Min(0) + bootSizeMiB?: number; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + updateBios?: boolean; + + @Field(() => OnboardingWizardPoolMode, { nullable: true }) + @IsOptional() + @IsEnum(OnboardingWizardPoolMode) + poolMode?: OnboardingWizardPoolMode; +} + +@InputType() +export class OnboardingWizardInternalBootDraftInput { + @Field(() => OnboardingWizardBootMode, { nullable: true }) + @IsOptional() + @IsEnum(OnboardingWizardBootMode) + bootMode?: OnboardingWizardBootMode; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + skipped?: boolean; + + @Field(() => OnboardingWizardInternalBootSelectionInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardInternalBootSelectionInput) + selection?: OnboardingWizardInternalBootSelectionInput | null; +} + +@InputType() +export class OnboardingWizardDraftInput { + @Field(() => OnboardingWizardCoreSettingsDraftInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardCoreSettingsDraftInput) + coreSettings?: OnboardingWizardCoreSettingsDraftInput; + + @Field(() => OnboardingWizardPluginsDraftInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardPluginsDraftInput) + plugins?: OnboardingWizardPluginsDraftInput; + + @Field(() => OnboardingWizardInternalBootDraftInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardInternalBootDraftInput) + internalBoot?: OnboardingWizardInternalBootDraftInput; +} + +@InputType() +export class OnboardingWizardNavigationInput { + @Field(() => OnboardingWizardStepId, { nullable: true }) + @IsOptional() + @IsEnum(OnboardingWizardStepId) + currentStepId?: OnboardingWizardStepId; +} + +@InputType() +export class OnboardingWizardInternalBootStateInput { + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + applyAttempted?: boolean; + + @Field(() => Boolean, { nullable: true }) + @IsOptional() + @IsBoolean() + applySucceeded?: boolean; +} + +@InputType() +export class SaveOnboardingDraftInput { + @Field(() => OnboardingWizardDraftInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardDraftInput) + draft?: OnboardingWizardDraftInput; + + @Field(() => OnboardingWizardNavigationInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardNavigationInput) + navigation?: OnboardingWizardNavigationInput; + + @Field(() => OnboardingWizardInternalBootStateInput, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => OnboardingWizardInternalBootStateInput) + internalBootState?: OnboardingWizardInternalBootStateInput; +} + +@InputType() +export class CloseOnboardingInput { + @Field(() => CloseOnboardingReason, { nullable: true }) + @IsOptional() + @IsEnum(CloseOnboardingReason) + reason?: CloseOnboardingReason; +} + @InputType({ description: 'Input for creating an internal boot pool during onboarding', }) 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 f16e8f524d..a0749c3135 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -1,3 +1,4 @@ +import { Logger } from '@nestjs/common'; import { Args, ResolveField, Resolver } from '@nestjs/graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; @@ -10,14 +11,18 @@ import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; import { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js'; import { + CloseOnboardingInput, CreateInternalBootPoolInput, OnboardingInternalBootContext, OnboardingInternalBootResult, OnboardingOverrideInput, + SaveOnboardingDraftInput, } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; @Resolver(() => OnboardingMutations) export class OnboardingMutationsResolver { + private readonly logger = new Logger(OnboardingMutationsResolver.name); + constructor( private readonly onboardingOverrides: OnboardingOverrideService, private readonly onboardingService: OnboardingService, @@ -67,7 +72,12 @@ export class OnboardingMutationsResolver { action: AuthAction.UPDATE_ANY, resource: Resource.WELCOME, }) - async closeOnboarding(): Promise { + async closeOnboarding( + @Args('input', { nullable: true }) input?: CloseOnboardingInput + ): Promise { + if (input?.reason) { + this.logger.warn(`closeOnboarding invoked with reason=${input.reason}`); + } await this.onboardingService.closeOnboarding(); return this.onboardingService.getOnboardingResponse(); } @@ -128,6 +138,18 @@ export class OnboardingMutationsResolver { return this.onboardingService.getOnboardingResponse(); } + @ResolveField(() => Boolean, { + description: 'Persist server-owned onboarding wizard draft state', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.WELCOME, + }) + async saveOnboardingDraft(@Args('input') input: SaveOnboardingDraftInput): Promise { + await this.onboardingService.saveOnboardingDraft(input); + return true; + } + @ResolveField(() => OnboardingInternalBootResult, { description: 'Create and configure internal boot pool via emcmd operations', }) From 8e4b90c0fa1cc67f529b4dd499bc5c86170e8a45 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Tue, 31 Mar 2026 17:27:23 -0400 Subject: [PATCH 02/52] feat(onboarding-web): drive the wizard from server bootstrap state - Purpose: make the web app consume server-owned onboarding wizard state while keeping only transient in-memory edits between step transitions. - Before: onboarding used a persisted Pinia draft store and client-owned step flow, so browser storage controlled resume behavior and stale draft keys could come back. - Problem: the client had too much ownership over wizard progress, save failures were not the durability boundary, and loading/error behavior around step transitions was inconsistent. - Now: the modal hydrates from server bootstrap data, persists only on step transitions, disables controls while a save is in flight, shows step-level save errors, and removes the legacy draft store entirely. Files changed in this commit: - web/src/components/Onboarding/OnboardingModal.vue: replaced the persisted draft-store flow with local in-memory wizard state, server hydration, save-on-transition logic, save-failure handling, and the close-on-failure escape path. - web/src/components/Onboarding/onboardingWizardState.ts: introduced shared client-side types/helpers for nested wizard draft state and internal-boot state cloning. - web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts: expanded bootstrap data to include the server-owned wizard payload. - web/src/components/Onboarding/graphql/saveOnboardingDraft.mutation.ts: added the new mutation used for transition-time persistence. - web/src/components/Onboarding/graphql/closeOnboarding.mutation.ts: updated the close mutation shape so the client can send save-failure reason metadata. - web/src/components/Onboarding/store/onboardingContextData.ts: exposed the server-provided wizard payload from the shared onboarding bootstrap query. - web/src/components/Onboarding/store/onboardingModalVisibility.ts: threaded close reasons through the existing modal-close mutation without changing global Pinia persistence behavior. - web/src/components/Onboarding/store/onboardingStorageCleanup.ts: narrowed browser cleanup to best-effort deletion of legacy onboarding draft keys and the old hidden-modal session flag. - web/src/components/Onboarding/store/onboardingDraft.ts: removed the old persisted onboarding draft store entirely. - web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue: switched to prop-driven draft hydration, returned draft snapshots on complete/back, and locked the form while transition saves run. - web/src/components/Onboarding/steps/OnboardingPluginsStep.vue: switched plugin selection to prop/callback-driven draft snapshots and surfaced transition-save loading/error state. - web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue: removed draft-store coupling, returned internal-boot draft snapshots, and respected transition-save locking for navigation. - web/src/components/Onboarding/steps/OnboardingSummaryStep.vue: consumed the server-backed nested draft/internal-boot state instead of reading from the old flat draft store. - web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue: continued the flow from the new server-backed summary state without relying on the removed draft store. - web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue: moved standalone internal-boot flow state to local refs and aligned it with the new draft/internal-boot types. - web/src/components/Onboarding/standalone/OnboardingAdminPanel.standalone.vue: updated legacy cleanup wording to describe deleting old browser draft keys rather than reviving the removed store model. - web/src/components/Onboarding/composables/internalBoot.ts: widened the internal-boot selection type so the new summary/standalone flows can carry slot count through the shared apply path. - web/src/composables/gql/gql.ts: regenerated GraphQL document mapping for the new onboarding operations. - web/src/composables/gql/graphql.ts: regenerated web GraphQL types/enums for the wizard payload and save/close inputs. - web/src/composables/gql/index.ts: refreshed generated GraphQL exports for the updated documents and types. --- .../components/Onboarding/OnboardingModal.vue | 542 +++++++++++++----- .../Onboarding/composables/internalBoot.ts | 1 + .../graphql/closeOnboarding.mutation.ts | 4 +- .../graphql/onboardingBootstrap.query.ts | 37 +- .../graphql/saveOnboardingDraft.mutation.ts | 9 + .../Onboarding/onboardingWizardState.ts | 83 +++ .../OnboardingAdminPanel.standalone.vue | 9 +- .../OnboardingInternalBoot.standalone.vue | 169 +++++- .../steps/OnboardingCoreSettingsStep.vue | 113 ++-- .../steps/OnboardingInternalBootStep.vue | 121 ++-- .../steps/OnboardingNextStepsStep.vue | 20 +- .../steps/OnboardingPluginsStep.vue | 61 +- .../steps/OnboardingSummaryStep.vue | 113 +++- .../Onboarding/store/onboardingContextData.ts | 4 +- .../Onboarding/store/onboardingDraft.ts | 339 ----------- .../store/onboardingModalVisibility.ts | 12 +- .../store/onboardingStorageCleanup.ts | 13 - web/src/composables/gql/gql.ts | 18 +- web/src/composables/gql/graphql.ts | 172 +++++- 19 files changed, 1162 insertions(+), 678 deletions(-) create mode 100644 web/src/components/Onboarding/graphql/saveOnboardingDraft.mutation.ts create mode 100644 web/src/components/Onboarding/onboardingWizardState.ts delete mode 100644 web/src/components/Onboarding/store/onboardingDraft.ts diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 729ea6bd6e..12b514768e 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -1,25 +1,43 @@ From 26708a401beb3495122e29a8882aa5ae457e4388 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Tue, 31 Mar 2026 17:48:07 -0400 Subject: [PATCH 06/52] fix(onboarding-ui): move transition loading into steps - Purpose: Move transition-save loading feedback out of the modal shell and into the active onboarding steps so the state reads as part of the step interaction itself. - Before: On step transitions, the modal rendered a generic overlay above the current step while `saveOnboardingDraft` was in flight. Primary forward buttons already showed loading, but Back and Skip only became disabled, which made the save state feel modal-owned instead of step-owned. - Problem: The overlay obscured the step content, made Back and Skip feel less explicit, and did not match the product intent of showing loading within the step the user just interacted with. - What changed: Remove the modal-level transition overlay from `OnboardingModal.vue` and keep the modal responsible only for full-modal loading states such as bootstrap and close. Add compact inline onboarding loading blocks directly above the footer in the Overview, Core Settings, Plugins, Internal Boot, License, and Summary steps. Extend the License and Summary step props to understand `isSavingStep`, then disable their interactive controls while a transition save is running. Keep the existing button-level loading states where they already existed, so forward actions still feel responsive while Back and Skip now gain the same visual context through the inline step loading block. - How it works: The modal still owns `isSavingTransition` and passes it through as `isSavingStep`, but each step now renders its own compact `OnboardingLoadingState` when that flag is true. Because the loading block is rendered inside the active step, the user sees the save feedback in the same place as the buttons they just used. License and Summary now also treat transition saves as a busy state, which prevents conflicting interactions during the save window without reintroducing the old overlay. --- .../components/Onboarding/OnboardingModal.vue | 15 -------------- .../steps/OnboardingCoreSettingsStep.vue | 9 +++++++++ .../steps/OnboardingInternalBootStep.vue | 8 ++++++++ .../steps/OnboardingLicenseStep.vue | 20 +++++++++++++++++-- .../steps/OnboardingOverviewStep.vue | 9 +++++++++ .../steps/OnboardingPluginsStep.vue | 8 ++++++++ .../steps/OnboardingSummaryStep.vue | 17 +++++++++++++--- 7 files changed, 66 insertions(+), 20 deletions(-) diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index b8f006ffc1..b7ff032d93 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -544,9 +544,6 @@ const isAwaitingStepData = computed( (!hasHydratedWizardState.value || (onboardingContextLoading.value && wizard.value === null)) ); const showModalLoadingState = computed(() => isClosingModal.value || isAwaitingStepData.value); -const showStepTransitionOverlay = computed( - () => isSavingTransition.value && !showModalLoadingState.value -); const loadingStateTitle = computed(() => isClosingModal.value ? t('onboarding.modal.closing.title') : t('onboarding.loading.title') ); @@ -941,18 +938,6 @@ const currentStepProps = computed>(() => { :is="currentStepComponent" v-bind="currentStepProps" /> -
-
- -
-
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue index 2079eb6383..595c30d2ea 100644 --- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue @@ -12,6 +12,7 @@ import azureThemeImg from '@/assets/unraid-azure-theme.png'; import blackThemeImg from '@/assets/unraid-black-theme.png'; import grayThemeImg from '@/assets/unraid-gray-theme.png'; import whiteThemeImg from '@/assets/unraid-white-theme.png'; +import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue'; // --- Language Logic --- import { GET_AVAILABLE_LANGUAGES_QUERY } from '@/components/Onboarding/graphql/availableLanguages.query'; import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; @@ -602,6 +603,14 @@ const stepError = computed(() => error.value ?? props.saveError ?? null);

+ +
t('onboarding.internalBootStep.actions. {{ stepError }}
+ +
diff --git a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue index 8f060fcc74..d605c7db01 100644 --- a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue @@ -14,6 +14,7 @@ import { } from '@heroicons/vue/24/solid'; import { BrandButton } from '@unraid/ui'; +import OnboardingLoadingState from '~/components/Onboarding/components/OnboardingLoadingState.vue'; import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; import { useServerStore } from '~/store/server'; @@ -21,6 +22,7 @@ interface Props { onComplete?: () => void; onBack?: () => void; showBack?: boolean; + isSavingStep?: boolean; activateHref: string; activateExternal?: boolean; allowSkip?: boolean; @@ -64,6 +66,7 @@ const isCodeRevealed = ref(false); const isHelpDialogOpen = ref(false); const isSkipDialogOpen = ref(false); const isRefreshing = ref(false); +const isBusy = computed(() => Boolean(props.isSavingStep) || isRefreshing.value); // Methods const openActivate = () => { @@ -171,7 +174,7 @@ const doSkip = () => { @click="refreshStatus" class="text-muted hover:text-primary -mt-1 -mr-2 p-1 transition-colors focus:outline-none" :title="lt('onboarding.licenseStep.actions.refreshStatus', 'Refresh Status')" - :disabled="isRefreshing" + :disabled="isBusy" > @@ -199,6 +202,7 @@ const doSkip = () => { + +
{ v-if="showBack" @click="handleBack" class="text-muted hover:text-toned group flex w-full items-center justify-center gap-2 font-medium transition-colors sm:w-auto sm:justify-start" + :disabled="Boolean(props.isSavingStep)" > {{ t('common.back') }} @@ -267,7 +282,8 @@ const doSkip = () => { :variant="hasValidLicense ? 'fill' : 'outline'" :class="hasValidLicense ? '!bg-primary hover:!bg-primary/90 !text-white' : '!shadow-none'" @click="handleNext" - :disabled="!hasValidLicense && !allowSkip" + :disabled="Boolean(props.isSavingStep) || (!hasValidLicense && !allowSkip)" + :loading="Boolean(props.isSavingStep)" :icon-right="ChevronRightIcon" />
diff --git a/web/src/components/Onboarding/steps/OnboardingOverviewStep.vue b/web/src/components/Onboarding/steps/OnboardingOverviewStep.vue index 27bd06cca4..7b1e95f885 100644 --- a/web/src/components/Onboarding/steps/OnboardingOverviewStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingOverviewStep.vue @@ -7,6 +7,7 @@ import { useMutation } from '@vue/apollo-composable'; import { ChevronRightIcon } from '@heroicons/vue/24/solid'; import { BrandButton } from '@unraid/ui'; import limitlessImage from '@/assets/limitless_possibilities.jpg'; +import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue'; import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation'; import { useActivationCodeDataStore } from '@/components/Onboarding/store/activationCodeData'; import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus'; @@ -296,6 +297,14 @@ const openDocs = () => {

--> + +
+ +
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue index 7c34ec728d..f1705655c8 100644 --- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue @@ -24,6 +24,7 @@ import { } from '@heroicons/vue/24/solid'; import { Accordion, BrandButton } from '@unraid/ui'; import OnboardingConsole from '@/components/Onboarding/components/OnboardingConsole.vue'; +import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue'; import { applyInternalBootSelection, getErrorMessage, @@ -67,6 +68,7 @@ export interface Props { onComplete: () => void | Promise; onBack?: () => void; showBack?: boolean; + isSavingStep?: boolean; } const props = defineProps(); @@ -546,6 +548,7 @@ const canApply = computed( const showApplyReadinessWarning = computed( () => !isApplyDataReady.value && (applyReadinessTimedOut.value || hasBaselineQueryError.value) ); +const isBusy = computed(() => isProcessing.value || Boolean(props.isSavingStep)); onMounted(() => { applyReadinessTimer = setTimeout(() => { @@ -1419,6 +1422,14 @@ const handleBack = () => {

+ + { v-if="showBack" @click="handleBack" class="text-muted hover:text-toned group flex items-center justify-center gap-2 font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto sm:justify-start" - :disabled="isProcessing" + :disabled="isBusy" > {{ t('common.back') }} @@ -1503,12 +1514,12 @@ const handleBack = () => { From aa7a1d959159263db8df8a0433e4bac2796a1055 Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Tue, 31 Mar 2026 17:54:19 -0400 Subject: [PATCH 07/52] fix(onboarding-ui): replace step content during transition saves - Purpose: Make transition-save loading states feel like true step replacements instead of supplemental content appended near the footer. - Before: The previous follow-up moved loading into each step, but it rendered as a compact block above the footer. That was better than the old modal overlay, yet it still visually competed with the existing step content and read like an extra message at the bottom. - Problem: Users could still perceive the step as mostly active because the main body remained visible. The loading state did not fully communicate that the current step had handed off control while the transition save was in flight. - What changed: Update the Overview, Core Settings, Plugins, Internal Boot, License, and Summary steps so `isSavingStep` swaps the normal step body for a centered `OnboardingLoadingState`. Remove the footer-level loading blocks that were added in the prior commit. Preserve the existing busy-state wiring and button disabling logic so interaction safety stays unchanged while the presentation becomes clearer. - How it works: Each affected step now gates its main card with `v-if` / `v-else`: when `isSavingStep` is true, the step renders only the centered loading state inside the step container. When the transition save completes, the normal step body returns or the wizard advances, which makes the loading state read as a full-step handoff instead of a footer status message. --- .../steps/OnboardingCoreSettingsStep.vue | 16 +++++++--------- .../steps/OnboardingInternalBootStep.vue | 16 +++++++--------- .../steps/OnboardingLicenseStep.vue | 19 ++++++++++--------- .../steps/OnboardingOverviewStep.vue | 16 +++++++--------- .../steps/OnboardingPluginsStep.vue | 16 +++++++--------- .../steps/OnboardingSummaryStep.vue | 16 +++++++--------- 6 files changed, 45 insertions(+), 54 deletions(-) diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue index 595c30d2ea..c3a58739e9 100644 --- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue @@ -417,7 +417,13 @@ const stepError = computed(() => error.value ?? props.saveError ?? null);