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 @@
@@ -593,11 +594,11 @@ const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
- {{ error }}
+ {{ stepError }}
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index c98a994638..5e0cce64b0 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -8,30 +8,31 @@ import { ArrowPathIcon, ChevronRightIcon } from '@heroicons/vue/24/solid';
import { Accordion, BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
-import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
+import {
+ type OnboardingBootMode,
+ type OnboardingInternalBootDraft,
+ type OnboardingInternalBootSelection,
+ type OnboardingPoolMode,
+} from '@/components/Onboarding/onboardingWizardState';
import { convert } from 'convert';
-import type {
- OnboardingBootMode,
- OnboardingInternalBootSelection,
- OnboardingPoolMode,
-} from '@/components/Onboarding/store/onboardingDraft';
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
import { GetInternalBootContextDocument } from '~/composables/gql/graphql';
export interface Props {
- onComplete: () => void;
- onSkip?: () => void;
- onBack?: () => void;
+ initialDraft?: OnboardingInternalBootDraft | null;
+ onComplete: (draft: OnboardingInternalBootDraft) => void | Promise;
+ onSkip?: (draft: OnboardingInternalBootDraft) => void | Promise;
+ onBack?: (draft: OnboardingInternalBootDraft) => void | Promise;
showSkip?: boolean;
showBack?: boolean;
isSavingStep?: boolean;
+ saveError?: string | null;
}
const props = defineProps();
const { t } = useI18n();
-const draftStore = useOnboardingDraftStore();
const toBootMode = (value: unknown): OnboardingBootMode => (value === 'storage' ? 'storage' : 'usb');
interface InternalBootDeviceOption {
@@ -145,7 +146,7 @@ const formError = ref(null);
const hasInitializedForm = ref(false);
const isRefreshingContext = ref(false);
const bootMode = ref(
- toBootMode(draftStore.bootMode ?? (draftStore.internalBootSelection ? 'storage' : 'usb'))
+ toBootMode(props.initialDraft?.bootMode ?? (props.initialDraft?.selection ? 'storage' : 'usb'))
);
const poolMode = ref('dedicated');
@@ -240,6 +241,7 @@ const templateData = computed(() => {
const isLoading = computed(() => Boolean(contextLoading.value) && !internalBootContext.value);
const isBusy = computed(() => Boolean(props.isSavingStep) || isLoading.value);
const isStepLocked = computed(() => Boolean(props.isSavingStep));
+const stepError = computed(() => props.saveError ?? null);
const internalBootTransferState = computed(() => {
const setting = internalBootContext.value?.enableBootTransfer;
if (typeof setting !== 'string') {
@@ -673,8 +675,37 @@ const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
};
};
+const buildDraftSnapshot = (): OnboardingInternalBootDraft => {
+ if (bootMode.value === 'usb') {
+ return {
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ };
+ }
+
+ const trimmedPoolName = poolName.value.trim();
+ const devices = selectedDevices.value
+ .slice(0, slotCount.value)
+ .filter((device): device is string => typeof device === 'string' && device.length > 0);
+ const currentBootSizeMiB = bootSizeMiB.value ?? undefined;
+
+ return {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: trimmedPoolName,
+ slotCount: slotCount.value,
+ devices,
+ bootSizeMiB: currentBootSizeMiB,
+ updateBios: updateBios.value,
+ poolMode: poolMode.value,
+ },
+ };
+};
+
const initializeForm = (data: InternalBootTemplateData) => {
- const draftSelection = draftStore.internalBootSelection;
+ const draftSelection = props.initialDraft?.selection ?? null;
const firstSlot = data.slotOptions[0] ?? 1;
const defaultSlot = Math.max(1, Math.min(2, firstSlot));
@@ -684,7 +715,7 @@ const initializeForm = (data: InternalBootTemplateData) => {
(poolMode.value === 'dedicated' ? 'boot' : (data.poolNameDefault ?? 'cache'));
slotCount.value = draftSelection?.slotCount ?? defaultSlot;
selectedDevices.value =
- draftSelection?.devices.slice(0, slotCount.value) ??
+ draftSelection?.devices?.slice(0, slotCount.value) ??
Array.from({ length: slotCount.value }, (): string | undefined => undefined);
normalizeSelectedDevices(slotCount.value);
@@ -705,47 +736,59 @@ watch(
);
watch(
- () => draftStore.internalBootSelection,
- (selection) => {
- if (!selection || isLoading.value) {
+ () => props.initialDraft,
+ (draft) => {
+ if (!draft || isLoading.value) {
+ return;
+ }
+
+ bootMode.value = toBootMode(draft.bootMode ?? (draft.selection ? 'storage' : 'usb'));
+ if (!draft.selection) {
return;
}
- poolMode.value = selection.poolMode ?? 'hybrid';
- poolName.value = selection.poolName;
- slotCount.value = selection.slotCount;
- selectedDevices.value = [...selection.devices];
+ poolMode.value = draft.selection.poolMode ?? 'hybrid';
+ poolName.value = draft.selection.poolName ?? poolName.value;
+ slotCount.value = draft.selection.slotCount ?? slotCount.value;
+ selectedDevices.value = [...(draft.selection.devices ?? [])];
normalizeSelectedDevices(slotCount.value);
- updateBios.value = selection.updateBios;
- applyBootSizeSelection(selection.bootSizeMiB);
+ updateBios.value = draft.selection.updateBios ?? updateBios.value;
+ applyBootSizeSelection(draft.selection.bootSizeMiB ?? DEFAULT_BOOT_SIZE_MIB);
}
);
watch(
- () => draftStore.bootMode,
+ () => props.initialDraft?.bootMode,
(mode) => {
bootMode.value = toBootMode(mode);
},
{ immediate: true }
);
-const handleBack = () => {
- props.onBack?.();
+const handleBack = async () => {
+ await props.onBack?.(buildDraftSnapshot());
};
-const handleSkip = () => {
- draftStore.skipInternalBoot();
+const handleSkip = async () => {
+ const draft: OnboardingInternalBootDraft = {
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ };
if (props.onSkip) {
- props.onSkip();
+ await props.onSkip(draft);
} else {
- props.onComplete();
+ await props.onComplete(draft);
}
};
-const handlePrimaryAction = () => {
+const handlePrimaryAction = async () => {
if (bootMode.value === 'usb') {
- draftStore.setBootMode('usb');
- props.onComplete();
+ await props.onComplete({
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ });
return;
}
@@ -759,8 +802,11 @@ const handlePrimaryAction = () => {
return;
}
- draftStore.setInternalBootSelection(selection);
- props.onComplete();
+ await props.onComplete({
+ bootMode: 'storage',
+ skipped: false,
+ selection,
+ });
};
const handleRefreshContext = async () => {
@@ -1128,6 +1174,13 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ formError }}
+
+ {{ stepError }}
+
+
diff --git a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
index db56b00a73..575a30dc1d 100644
--- a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
@@ -24,11 +24,17 @@ import {
} from '@/components/Onboarding/composables/internalBoot';
import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation';
import { useActivationCodeDataStore } from '@/components/Onboarding/store/activationCodeData';
-import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus';
import { cleanupOnboardingStorage } from '@/components/Onboarding/store/onboardingStorageCleanup';
+import type {
+ OnboardingWizardDraft,
+ OnboardingWizardInternalBootState,
+} from '@/components/Onboarding/onboardingWizardState';
+
export interface Props {
+ draft: OnboardingWizardDraft;
+ internalBootState: OnboardingWizardInternalBootState;
onComplete: () => void;
onBack?: () => void;
showBack?: boolean;
@@ -37,7 +43,6 @@ export interface Props {
const props = defineProps
();
const { t } = useI18n();
const store = useActivationCodeDataStore();
-const draftStore = useOnboardingDraftStore();
const { mutate: completeOnboarding } = useMutation(COMPLETE_ONBOARDING_MUTATION);
const { refetchOnboarding } = useOnboardingStore();
@@ -58,15 +63,16 @@ const hasExtraLinks = computed(() => (partnerInfo.value?.partner?.extraLinks?.le
// Check if we have any content to show in the "Learn about your server" section
// Only show if there are LINKS (docs or extra links) - system specs alone isn't enough
const hasAnyPartnerContent = computed(() => hasCoreDocsLinks.value || hasExtraLinks.value);
-const showRebootButton = computed(() => draftStore.internalBootSelection !== null);
+const internalBootSelection = computed(() => props.draft.internalBoot?.selection ?? null);
+const showRebootButton = computed(() => internalBootSelection.value !== null);
const internalBootFailed = computed(
() =>
- draftStore.internalBootSelection !== null &&
- draftStore.internalBootApplyAttempted &&
- !draftStore.internalBootApplySucceeded
+ internalBootSelection.value !== null &&
+ props.internalBootState.applyAttempted &&
+ !props.internalBootState.applySucceeded
);
const biosUpdateMissed = computed(
- () => internalBootFailed.value && (draftStore.internalBootSelection?.updateBios ?? false)
+ () => internalBootFailed.value && (internalBootSelection.value?.updateBios ?? false)
);
const primaryButtonText = computed(() =>
showRebootButton.value
diff --git a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
index 259a90965b..22dc9823a6 100644
--- a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
@@ -8,20 +8,22 @@ import { ChevronRightIcon } from '@heroicons/vue/24/solid';
import { BrandButton } from '@unraid/ui';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query';
-import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
+
+import type { OnboardingPluginsDraft } from '@/components/Onboarding/onboardingWizardState';
export interface Props {
- onComplete: () => void;
- onSkip?: () => void;
- onBack?: () => void;
+ initialDraft?: OnboardingPluginsDraft | null;
+ onComplete: (draft: OnboardingPluginsDraft) => void | Promise;
+ onSkip?: (draft: OnboardingPluginsDraft) => void | Promise;
+ onBack?: (draft: OnboardingPluginsDraft) => void | Promise;
showSkip?: boolean;
showBack?: boolean;
isSavingStep?: boolean;
+ saveError?: string | null;
}
const props = defineProps();
const { t } = useI18n();
-const draftStore = useOnboardingDraftStore();
const normalizePluginFileName = (value: string) => value.trim().toLowerCase();
@@ -68,14 +70,12 @@ const getPluginInstallDetectionFileNames = (plugin: { id: string; url: string })
};
const defaultSelectedPluginIds = new Set(['community-apps']);
+const buildSelectedPluginsFromDraft = (draft?: OnboardingPluginsDraft | null) => {
+ const selectedIds = draft?.selectedIds ?? [];
+ return selectedIds.length > 0 ? new Set(selectedIds) : new Set(defaultSelectedPluginIds);
+};
-// Respect persisted draft selections after first interaction with this step.
-// On first visit, keep Community Apps selected and leave the rest optional.
-const initialSelection = draftStore.pluginSelectionInitialized
- ? new Set(draftStore.selectedPlugins)
- : defaultSelectedPluginIds;
-
-const selectedPlugins = ref>(initialSelection);
+const selectedPlugins = ref>(buildSelectedPluginsFromDraft(props.initialDraft));
const installedPluginIds = ref>(new Set());
const { result: installedPluginsResult, loading: installedPluginsLoading } = useQuery(
@@ -94,9 +94,13 @@ const isInstalledPluginsPending = computed(
installedPluginsLoading.value && !Array.isArray(installedPluginsResult.value?.installedUnraidPlugins)
);
const isBusy = computed(() => Boolean(props.isSavingStep) || isInstalledPluginsPending.value);
+const stepError = computed(() => props.saveError ?? null);
const persistedSelectedPlugins = computed(
() => new Set([...selectedPlugins.value, ...installedPluginIds.value])
);
+const buildDraftSnapshot = (): OnboardingPluginsDraft => ({
+ selectedIds: Array.from(persistedSelectedPlugins.value),
+});
const applyInstalledPlugins = (installedPlugins: string[] | null | undefined) => {
if (!Array.isArray(installedPlugins)) {
@@ -130,6 +134,13 @@ watch(
{ immediate: true }
);
+watch(
+ () => props.initialDraft,
+ (draft) => {
+ selectedPlugins.value = buildSelectedPluginsFromDraft(draft);
+ }
+);
+
const togglePlugin = (pluginId: string, value: boolean) => {
if (installedPluginIds.value.has(pluginId) || isBusy.value) {
return;
@@ -144,22 +155,23 @@ const togglePlugin = (pluginId: string, value: boolean) => {
selectedPlugins.value = next;
};
-const handleSkip = () => {
- draftStore.setPlugins(new Set(installedPluginIds.value));
+const handleSkip = async () => {
+ const draft: OnboardingPluginsDraft = {
+ selectedIds: Array.from(installedPluginIds.value),
+ };
if (props.onSkip) {
- props.onSkip();
+ await props.onSkip(draft);
} else {
- props.onComplete();
+ await props.onComplete(draft);
}
};
-const handleBack = () => {
- props.onBack?.();
+const handleBack = async () => {
+ await props.onBack?.(buildDraftSnapshot());
};
const handlePrimaryAction = async () => {
- draftStore.setPlugins(persistedSelectedPlugins.value);
- props.onComplete();
+ await props.onComplete(buildDraftSnapshot());
};
const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
@@ -230,6 +242,15 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
+
+
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index e26e6a4899..7c34ec728d 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -45,11 +45,15 @@ import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/upd
import { convert } from 'convert';
import type { LogEntry } from '@/components/Onboarding/components/OnboardingConsole.vue';
+import type { InternalBootSelection } from '@/components/Onboarding/composables/internalBoot';
import type { OnboardingErrorDiagnostics } from '@/components/Onboarding/composables/onboardingErrorDiagnostics';
+import type {
+ OnboardingWizardDraft,
+ OnboardingWizardInternalBootState,
+} from '@/components/Onboarding/onboardingWizardState';
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData';
-import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft';
import {
GetInternalBootContextDocument,
PluginInstallStatus,
@@ -57,6 +61,9 @@ import {
} from '~/composables/gql/graphql';
export interface Props {
+ draft: OnboardingWizardDraft;
+ internalBootState: OnboardingWizardInternalBootState;
+ onInternalBootStateChange?: (state: OnboardingWizardInternalBootState) => void | Promise
;
onComplete: () => void | Promise;
onBack?: () => void;
showBack?: boolean;
@@ -64,8 +71,18 @@ export interface Props {
const props = defineProps();
const { t } = useI18n();
-const draftStore = useOnboardingDraftStore();
const { activationCode, isFreshInstall, registrationState } = storeToRefs(useActivationCodeDataStore());
+const draftCoreSettings = computed(() => props.draft.coreSettings ?? {});
+const draftPlugins = computed(() => props.draft.plugins?.selectedIds ?? []);
+const draftInternalBoot = computed(() => props.draft.internalBoot ?? {});
+const internalBootSelection = computed(() => draftInternalBoot.value.selection ?? null);
+const setInternalBootState = (state: Partial) => {
+ const nextState: OnboardingWizardInternalBootState = {
+ applyAttempted: state.applyAttempted ?? props.internalBootState.applyAttempted,
+ applySucceeded: state.applySucceeded ?? props.internalBootState.applySucceeded,
+ };
+ void props.onInternalBootStateChange?.(nextState);
+};
// Setup Mutations
const { mutate: updateSystemTime } = useMutation(UPDATE_SYSTEM_TIME_MUTATION);
const { mutate: updateServerIdentity } = useMutation(UPDATE_SERVER_IDENTITY_MUTATION);
@@ -97,11 +114,11 @@ const { result: internalBootContextResult } = useQuery(GetInternalBootContextDoc
fetchPolicy: 'network-only',
});
-const draftPluginsCount = computed(() => draftStore.selectedPlugins?.size ?? 0);
+const draftPluginsCount = computed(() => draftPlugins.value.length);
const currentTimeZone = computed(() => {
return (
- draftStore.selectedTimeZone ||
+ draftCoreSettings.value.timeZone ||
coreSettingsResult.value?.systemTime?.timeZone ||
t('onboarding.coreSettings.notConfigured')
);
@@ -109,7 +126,7 @@ const currentTimeZone = computed(() => {
const serverName = computed(() => {
return (
- draftStore.serverName ||
+ draftCoreSettings.value.serverName ||
coreSettingsResult.value?.vars?.name ||
t('onboarding.coreSettings.defaultServerName')
);
@@ -117,33 +134,33 @@ const serverName = computed(() => {
const activationSystemModel = computed(() => activationCode.value?.system?.model?.trim() || undefined);
-const sshEnabled = computed(() => {
- return draftStore.useSsh;
-});
+const sshEnabled = computed(() => Boolean(draftCoreSettings.value.useSsh));
const displayTheme = computed(() => {
- return draftStore.selectedTheme || coreSettingsResult.value?.display?.theme || 'white';
+ return draftCoreSettings.value.theme || coreSettingsResult.value?.display?.theme || 'white';
});
const displayLanguage = computed(() => {
- return draftStore.selectedLanguage || coreSettingsResult.value?.display?.locale || 'en_US';
+ return draftCoreSettings.value.language || coreSettingsResult.value?.display?.locale || 'en_US';
});
const summaryServerDescription = computed(
- () => draftStore.serverDescription || coreSettingsResult.value?.server?.comment || ''
+ () => draftCoreSettings.value.serverDescription || coreSettingsResult.value?.server?.comment || ''
);
const showBootConfiguration = computed(
- () => draftStore.internalBootInitialized && !draftStore.internalBootSkipped
+ () =>
+ !draftInternalBoot.value.skipped &&
+ (draftInternalBoot.value.bootMode === 'usb' || Boolean(draftInternalBoot.value.selection))
+);
+const selectedBootMode = computed(
+ () => draftInternalBoot.value.bootMode ?? (draftInternalBoot.value.selection ? 'storage' : 'usb')
);
-const selectedBootMode = computed(() => (draftStore.internalBootSelection ? 'storage' : 'usb'));
const bootModeLabel = computed(() =>
selectedBootMode.value === 'storage'
? t('onboarding.summaryStep.bootConfig.bootMethodStorage')
: t('onboarding.summaryStep.bootConfig.bootMethodUsb')
);
-const internalBootSelection = computed(() => draftStore.internalBootSelection ?? null);
-
const hasInternalBootSelection = computed(() => Boolean(internalBootSelection.value));
const formatBootSize = (bootSizeMiB: number) => {
@@ -212,11 +229,35 @@ const internalBootSummary = computed(() => {
poolName: selection.poolName,
slotCount: selection.slotCount,
devices: selection.devices,
- bootReservedSize: formatBootSize(selection.bootSizeMiB),
+ bootReservedSize: formatBootSize(selection.bootSizeMiB ?? 0),
updateBios: selection.updateBios,
};
});
+const toAppliedInternalBootSelection = (
+ selection: NonNullable
+): InternalBootSelection | null => {
+ if (
+ !selection.poolName ||
+ typeof selection.slotCount !== 'number' ||
+ !Array.isArray(selection.devices) ||
+ typeof selection.bootSizeMiB !== 'number' ||
+ typeof selection.updateBios !== 'boolean' ||
+ (selection.poolMode !== 'dedicated' && selection.poolMode !== 'hybrid')
+ ) {
+ return null;
+ }
+
+ return {
+ poolName: selection.poolName,
+ slotCount: selection.slotCount,
+ devices: [...selection.devices],
+ bootSizeMiB: selection.bootSizeMiB,
+ updateBios: selection.updateBios,
+ poolMode: selection.poolMode,
+ };
+};
+
// Processing State
const isProcessing = ref(false);
const error = ref(null);
@@ -407,12 +448,16 @@ interface CoreSettingsSnapshot {
}
const resolveTargetCoreSettings = (): CoreSettingsSnapshot => ({
- serverName: draftStore.serverName || TRUSTED_DEFAULT_PROFILE.serverName,
- serverDescription: draftStore.serverDescription || TRUSTED_DEFAULT_PROFILE.serverDescription,
- timeZone: draftStore.selectedTimeZone || TRUSTED_DEFAULT_PROFILE.timeZone,
- theme: normalizeThemeName(draftStore.selectedTheme || TRUSTED_DEFAULT_PROFILE.theme),
- locale: draftStore.selectedLanguage || TRUSTED_DEFAULT_PROFILE.locale,
- useSsh: typeof draftStore.useSsh === 'boolean' ? draftStore.useSsh : TRUSTED_DEFAULT_PROFILE.useSsh,
+ serverName: draftCoreSettings.value.serverName || TRUSTED_DEFAULT_PROFILE.serverName,
+ serverDescription:
+ draftCoreSettings.value.serverDescription || TRUSTED_DEFAULT_PROFILE.serverDescription,
+ timeZone: draftCoreSettings.value.timeZone || TRUSTED_DEFAULT_PROFILE.timeZone,
+ theme: normalizeThemeName(draftCoreSettings.value.theme || TRUSTED_DEFAULT_PROFILE.theme),
+ locale: draftCoreSettings.value.language || TRUSTED_DEFAULT_PROFILE.locale,
+ useSsh:
+ typeof draftCoreSettings.value.useSsh === 'boolean'
+ ? draftCoreSettings.value.useSsh
+ : TRUSTED_DEFAULT_PROFILE.useSsh,
});
const normalizePluginFileName = (value: string) => value.trim().toLowerCase();
@@ -457,7 +502,7 @@ const installedPluginFileNames = computed(() => {
});
const pluginIdsToInstall = computed(() => {
- return Array.from(draftStore.selectedPlugins).filter((pluginId) => {
+ return draftPlugins.value.filter((pluginId) => {
const details = pluginMap[pluginId];
if (!details) return false;
const detectionFileNames = getPluginInstallDetectionFileNames(details);
@@ -469,7 +514,7 @@ const pluginIdsToInstall = computed(() => {
});
const selectedPluginSummaries = computed(() => {
- return Array.from(draftStore.selectedPlugins).map((pluginId) => {
+ return draftPlugins.value.map((pluginId) => {
const details = pluginMap[pluginId];
const pluginName = details?.name ?? pluginId;
const pluginDetectionFileNames = details ? getPluginInstallDetectionFileNames(details) : null;
@@ -583,7 +628,10 @@ const handleComplete = async () => {
shouldReloadAfterApplyResult.value = false;
addLog(summaryT('logs.startingConfiguration'), 'info');
- draftStore.setInternalBootApplySucceeded(false);
+ setInternalBootState({
+ applyAttempted: props.internalBootState.applyAttempted,
+ applySucceeded: false,
+ });
if (showApplyReadinessWarning.value) {
addLog(summaryT('logs.baselineUnavailable'), 'info');
}
@@ -928,8 +976,14 @@ const handleComplete = async () => {
// 3. Internal boot setup
if (internalBootSelection.value) {
- const selection = internalBootSelection.value;
- draftStore.setInternalBootApplyAttempted(true);
+ const selection = toAppliedInternalBootSelection(internalBootSelection.value);
+ if (!selection) {
+ throw new Error('Internal boot selection is incomplete');
+ }
+ setInternalBootState({
+ applyAttempted: true,
+ applySucceeded: false,
+ });
addLog(summaryT('logs.internalBootStart'), 'info');
addLog(summaryT('logs.internalBootConfiguring'), 'info');
const internalBootProgressTimer = setInterval(() => {
@@ -944,7 +998,10 @@ const handleComplete = async () => {
});
if (applyResult.applySucceeded) {
- draftStore.setInternalBootApplySucceeded(true);
+ setInternalBootState({
+ applyAttempted: true,
+ applySucceeded: true,
+ });
}
hadWarnings ||= applyResult.hadWarnings;
diff --git a/web/src/components/Onboarding/store/onboardingContextData.ts b/web/src/components/Onboarding/store/onboardingContextData.ts
index 8cf87ae239..0f44a73d3a 100644
--- a/web/src/components/Onboarding/store/onboardingContextData.ts
+++ b/web/src/components/Onboarding/store/onboardingContextData.ts
@@ -14,18 +14,18 @@ export const useOnboardingContextDataStore = defineStore('onboardingContextData'
const onboarding = computed(() => onboardingBootstrapResult.value?.customization?.onboarding ?? null);
const onboardingState = computed(() => onboarding.value?.onboardingState ?? null);
+ const wizard = computed(() => onboarding.value?.wizard ?? null);
const activationCode = computed(
() => onboardingBootstrapResult.value?.customization?.activationCode ?? null
);
- const internalBootVisibility = computed(() => onboardingBootstrapResult.value?.vars ?? null);
return {
loading: computed(() => onboardingBootstrapLoading.value),
error: computed(() => onboardingBootstrapError.value),
onboarding,
onboardingState,
+ wizard,
activationCode,
- internalBootVisibility,
refetchOnboardingContext: refetch,
};
});
diff --git a/web/src/components/Onboarding/store/onboardingDraft.ts b/web/src/components/Onboarding/store/onboardingDraft.ts
deleted file mode 100644
index 10a17a45cb..0000000000
--- a/web/src/components/Onboarding/store/onboardingDraft.ts
+++ /dev/null
@@ -1,339 +0,0 @@
-import { computed, ref } from 'vue';
-import { defineStore } from 'pinia';
-
-import type { StepId } from '~/components/Onboarding/stepRegistry.js';
-
-import { STEP_IDS } from '~/components/Onboarding/stepRegistry.js';
-
-export type OnboardingPoolMode = 'dedicated' | 'hybrid';
-
-export interface OnboardingInternalBootSelection {
- poolName: string;
- slotCount: number;
- devices: string[];
- bootSizeMiB: number;
- updateBios: boolean;
- poolMode: OnboardingPoolMode;
-}
-
-export type OnboardingBootMode = 'usb' | 'storage';
-
-const normalizePersistedBoolean = (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;
- }
- }
-
- if (typeof value === 'number') {
- if (value === 1) {
- return true;
- }
- if (value === 0) {
- return false;
- }
- }
-
- return fallback;
-};
-
-const normalizePersistedPlugins = (value: unknown): string[] => {
- if (Array.isArray(value)) {
- return value.filter((item): item is string => typeof item === 'string');
- }
-
- if (value instanceof Set) {
- return Array.from(value).filter((item): item is string => typeof item === 'string');
- }
-
- return [];
-};
-
-const normalizePersistedPoolMode = (value: unknown): OnboardingPoolMode => {
- if (value === 'dedicated' || value === 'hybrid') {
- return value;
- }
- return 'hybrid';
-};
-
-const normalizePersistedInternalBootSelection = (
- value: unknown
-): OnboardingInternalBootSelection | null => {
- if (!value || typeof value !== 'object') {
- return null;
- }
-
- const candidate = value as {
- poolName?: unknown;
- slotCount?: unknown;
- devices?: unknown;
- bootSizeMiB?: unknown;
- updateBios?: unknown;
- poolMode?: unknown;
- };
-
- const poolMode = normalizePersistedPoolMode(candidate.poolMode);
- const poolName = typeof candidate.poolName === 'string' ? candidate.poolName : '';
- const parsedSlotCount = Number(candidate.slotCount);
- const slotCount = Number.isFinite(parsedSlotCount) ? Math.max(1, Math.min(2, parsedSlotCount)) : 1;
- const devices = Array.isArray(candidate.devices)
- ? candidate.devices.filter((item): item is string => typeof item === 'string')
- : [];
- const parsedBootSize = Number(candidate.bootSizeMiB);
- const bootSizeMiB =
- poolMode === 'dedicated'
- ? 0
- : Number.isFinite(parsedBootSize) && parsedBootSize > 0
- ? parsedBootSize
- : 16384;
-
- return {
- poolName,
- slotCount,
- devices,
- bootSizeMiB,
- updateBios: normalizePersistedBoolean(candidate.updateBios, false),
- poolMode,
- };
-};
-
-const normalizePersistedBootMode = (
- value: unknown,
- internalBootSelection: OnboardingInternalBootSelection | null
-): OnboardingBootMode => {
- if (value === 'usb' || value === 'storage') {
- return value;
- }
- return internalBootSelection ? 'storage' : 'usb';
-};
-
-const normalizePersistedStepId = (value: unknown): StepId | null => {
- if (typeof value !== 'string') {
- return null;
- }
-
- return STEP_IDS.includes(value as StepId) ? (value as StepId) : null;
-};
-
-export const useOnboardingDraftStore = defineStore(
- 'onboardingDraft',
- () => {
- // Core Settings
- const serverName = ref('');
- const serverDescription = ref('');
- const selectedTimeZone = ref('');
- const selectedTheme = ref('');
- const selectedLanguage = ref('');
- const useSsh = ref(false);
- const coreSettingsInitialized = ref(false);
-
- // Plugins
- const selectedPlugins = ref>(new Set());
- const pluginSelectionInitialized = ref(false);
-
- // Internal boot
- const internalBootSelection = ref(null);
- const bootMode = ref('usb');
- const internalBootInitialized = ref(false);
- const internalBootSkipped = ref(false);
- const internalBootApplySucceeded = ref(false);
- const internalBootApplyAttempted = ref(false);
-
- // Navigation
- const currentStepId = ref(null);
- const hasResumableDraft = computed(
- () =>
- currentStepId.value !== null ||
- coreSettingsInitialized.value ||
- pluginSelectionInitialized.value ||
- internalBootInitialized.value ||
- internalBootApplySucceeded.value
- );
-
- function resetDraft() {
- serverName.value = '';
- serverDescription.value = '';
- selectedTimeZone.value = '';
- selectedTheme.value = '';
- selectedLanguage.value = '';
- useSsh.value = false;
- coreSettingsInitialized.value = false;
-
- selectedPlugins.value = new Set();
- pluginSelectionInitialized.value = false;
-
- internalBootSelection.value = null;
- bootMode.value = 'usb';
- internalBootInitialized.value = false;
- internalBootSkipped.value = false;
- internalBootApplySucceeded.value = false;
- internalBootApplyAttempted.value = false;
-
- currentStepId.value = null;
- }
-
- // Actions
- function setCoreSettings(settings: {
- serverName: string;
- serverDescription: string;
- timeZone: string;
- theme: string;
- language: string;
- useSsh: boolean;
- }) {
- serverName.value = settings.serverName;
- serverDescription.value = settings.serverDescription;
- selectedTimeZone.value = settings.timeZone;
- selectedTheme.value = settings.theme;
- selectedLanguage.value = settings.language;
- useSsh.value = settings.useSsh;
- coreSettingsInitialized.value = true;
- }
-
- function setPlugins(plugins: Set) {
- selectedPlugins.value = new Set(plugins);
- pluginSelectionInitialized.value = true;
- }
-
- function setInternalBootSelection(selection: OnboardingInternalBootSelection) {
- internalBootSelection.value = {
- poolName: selection.poolName,
- slotCount: selection.slotCount,
- devices: [...selection.devices],
- bootSizeMiB: selection.bootSizeMiB,
- updateBios: selection.updateBios,
- poolMode: selection.poolMode,
- };
- bootMode.value = 'storage';
- internalBootInitialized.value = true;
- internalBootSkipped.value = false;
- internalBootApplySucceeded.value = false;
- }
-
- function skipInternalBoot() {
- internalBootSelection.value = null;
- bootMode.value = 'usb';
- internalBootInitialized.value = true;
- internalBootSkipped.value = true;
- internalBootApplySucceeded.value = false;
- }
-
- function setBootMode(mode: OnboardingBootMode) {
- bootMode.value = mode;
- internalBootInitialized.value = true;
- internalBootSkipped.value = false;
- if (mode === 'usb') {
- internalBootSelection.value = null;
- internalBootApplySucceeded.value = false;
- }
- }
-
- function setInternalBootApplySucceeded(value: boolean) {
- internalBootApplySucceeded.value = value;
- }
-
- function setInternalBootApplyAttempted(value: boolean) {
- internalBootApplyAttempted.value = value;
- }
-
- function setCurrentStep(stepId: StepId) {
- currentStepId.value = stepId;
- }
-
- return {
- serverName,
- serverDescription,
- selectedTimeZone,
- selectedTheme,
- selectedLanguage,
- useSsh,
- coreSettingsInitialized,
- selectedPlugins,
- pluginSelectionInitialized,
- internalBootSelection,
- bootMode,
- internalBootInitialized,
- internalBootSkipped,
- internalBootApplySucceeded,
- internalBootApplyAttempted,
- currentStepId,
- hasResumableDraft,
- resetDraft,
- setCoreSettings,
- setPlugins,
- setInternalBootSelection,
- skipInternalBoot,
- setBootMode,
- setInternalBootApplySucceeded,
- setInternalBootApplyAttempted,
- setCurrentStep,
- };
- },
- {
- persist: {
- serializer: {
- serialize: (value) => {
- const state = value as Record;
- const selectedPlugins = normalizePersistedPlugins(state.selectedPlugins);
- return JSON.stringify({ ...state, selectedPlugins });
- },
- deserialize: (value) => {
- const parsed = JSON.parse(value) as Record;
- const { currentStepIndex: _ignoredCurrentStepIndex, ...persistedState } = parsed;
- const normalizedInternalBootSelection = normalizePersistedInternalBootSelection(
- parsed.internalBootSelection
- );
- const normalizedBootMode = normalizePersistedBootMode(
- parsed.bootMode,
- normalizedInternalBootSelection
- );
- const normalizedCurrentStepId = normalizePersistedStepId(parsed.currentStepId);
- const hasLegacyCoreDraft =
- (typeof parsed.serverName === 'string' && parsed.serverName.length > 0) ||
- (typeof parsed.serverDescription === 'string' && parsed.serverDescription.length > 0) ||
- (typeof parsed.selectedTimeZone === 'string' && parsed.selectedTimeZone.length > 0) ||
- (typeof parsed.selectedTheme === 'string' && parsed.selectedTheme.length > 0) ||
- (typeof parsed.selectedLanguage === 'string' && parsed.selectedLanguage.length > 0) ||
- parsed.useSsh === true;
- const hadLegacyPluginShape =
- parsed.selectedPlugins !== undefined &&
- parsed.selectedPlugins !== null &&
- !Array.isArray(parsed.selectedPlugins);
- return {
- ...persistedState,
- selectedPlugins: new Set(normalizePersistedPlugins(parsed.selectedPlugins)),
- internalBootSelection: normalizedInternalBootSelection,
- bootMode: normalizedBootMode,
- internalBootInitialized: normalizePersistedBoolean(parsed.internalBootInitialized, false),
- internalBootSkipped:
- parsed.internalBootSkipped !== undefined
- ? normalizePersistedBoolean(parsed.internalBootSkipped, normalizedBootMode === 'usb')
- : normalizedBootMode === 'usb',
- internalBootApplySucceeded: normalizePersistedBoolean(
- parsed.internalBootApplySucceeded,
- false
- ),
- internalBootApplyAttempted: normalizePersistedBoolean(
- parsed.internalBootApplyAttempted,
- false
- ),
- currentStepId: normalizedCurrentStepId,
- coreSettingsInitialized:
- hasLegacyCoreDraft || normalizePersistedBoolean(parsed.coreSettingsInitialized, false),
- pluginSelectionInitialized: hadLegacyPluginShape
- ? false
- : normalizePersistedBoolean(parsed.pluginSelectionInitialized, false),
- };
- },
- },
- },
- }
-);
diff --git a/web/src/components/Onboarding/store/onboardingModalVisibility.ts b/web/src/components/Onboarding/store/onboardingModalVisibility.ts
index 2fcb218a91..27244da34b 100644
--- a/web/src/components/Onboarding/store/onboardingModalVisibility.ts
+++ b/web/src/components/Onboarding/store/onboardingModalVisibility.ts
@@ -7,6 +7,7 @@ import { OPEN_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/openOn
import { RESUME_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/resumeOnboarding.mutation';
import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus';
import { clearLegacyOnboardingModalHiddenSessionState } from '~/components/Onboarding/store/onboardingStorageCleanup';
+import { CloseOnboardingReason as GraphqlCloseOnboardingReason } from '~/composables/gql/graphql';
import { useCallbackActionsStore } from '~/store/callbackActions';
const ONBOARDING_QUERY_ACTION_PARAM = 'onboarding';
@@ -15,6 +16,7 @@ const ONBOARDING_URL_ACTION_OPEN = 'open';
const ONBOARDING_FORCE_OPEN_EVENT = 'unraid:onboarding:open';
export type OnboardingModalSessionSource = 'automatic' | 'manual';
+export type CloseOnboardingReason = 'SAVE_FAILURE';
export const useOnboardingModalStore = defineStore('onboardingModalVisibility', () => {
const onboardingStore = useOnboardingStore();
@@ -41,12 +43,18 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
return true;
};
- const closeModal = async () => {
+ const closeModal = async (reason?: CloseOnboardingReason) => {
if (!canDisplayOnboardingModal.value) {
return false;
}
- await closeOnboardingMutation();
+ await closeOnboardingMutation({
+ input: reason
+ ? {
+ reason: GraphqlCloseOnboardingReason.SAVE_FAILURE,
+ }
+ : undefined,
+ });
await refetchOnboarding();
sessionSource.value = 'automatic';
return true;
diff --git a/web/src/components/Onboarding/store/onboardingStorageCleanup.ts b/web/src/components/Onboarding/store/onboardingStorageCleanup.ts
index fb2df6f644..4d01362fda 100644
--- a/web/src/components/Onboarding/store/onboardingStorageCleanup.ts
+++ b/web/src/components/Onboarding/store/onboardingStorageCleanup.ts
@@ -1,7 +1,4 @@
-import { getActivePinia } from 'pinia';
-
import { ONBOARDING_MODAL_HIDDEN_STORAGE_KEY } from '~/components/Onboarding/constants';
-import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft';
const ONBOARDING_DRAFT_STORAGE_KEY = 'onboardingDraft';
@@ -17,17 +14,7 @@ const getLocalStorageKeys = () => {
}
};
-const resetLiveOnboardingDraftStore = () => {
- if (!getActivePinia()) {
- return;
- }
-
- useOnboardingDraftStore().resetDraft();
-};
-
export const clearOnboardingDraftStorage = () => {
- resetLiveOnboardingDraftStore();
-
if (typeof window === 'undefined') {
return;
}
diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts
index b09e234a71..700c4adf3b 100644
--- a/web/src/composables/gql/gql.ts
+++ b/web/src/composables/gql/gql.ts
@@ -67,7 +67,7 @@ type Documents = {
"\n subscription NotificationsWarningsAndAlertsSub {\n notificationsWarningsAndAlerts {\n ...NotificationFragment\n }\n }\n": typeof types.NotificationsWarningsAndAlertsSubDocument,
"\n query GetAvailableLanguages {\n customization {\n availableLanguages {\n code\n name\n url\n }\n }\n }\n": typeof types.GetAvailableLanguagesDocument,
"\n mutation BypassOnboarding {\n onboarding {\n bypassOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.BypassOnboardingDocument,
- "\n mutation CloseOnboarding {\n onboarding {\n closeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.CloseOnboardingDocument,
+ "\n mutation CloseOnboarding($input: CloseOnboardingInput) {\n onboarding {\n closeOnboarding(input: $input) {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.CloseOnboardingDocument,
"\n mutation CompleteOnboarding {\n onboarding {\n completeOnboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.CompleteOnboardingDocument,
"\n mutation UpdateServerIdentity($name: String!, $comment: String, $sysModel: String) {\n updateServerIdentity(name: $name, comment: $comment, sysModel: $sysModel) {\n id\n name\n comment\n }\n }\n": typeof types.UpdateServerIdentityDocument,
"\n mutation SetLocale($locale: String!) {\n customization {\n setLocale(locale: $locale)\n }\n }\n": typeof types.SetLocaleDocument,
@@ -78,12 +78,13 @@ type Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": typeof types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n }\n }\n vars {\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n }\n }\n": typeof types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": typeof types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": typeof types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": typeof types.PluginInstallUpdatesDocument,
"\n mutation RefreshInternalBootContext {\n onboarding {\n refreshInternalBootContext {\n bootEligible\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n reservedNames\n shareNames\n poolNames\n driveWarnings {\n diskId\n device\n warnings\n }\n assignableDisks {\n id\n device\n size\n serialNum\n interfaceType\n }\n }\n }\n }\n": typeof types.RefreshInternalBootContextDocument,
"\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.ResumeOnboardingDocument,
+ "\n mutation SaveOnboardingDraft($input: SaveOnboardingDraftInput!) {\n onboarding {\n saveOnboardingDraft(input: $input)\n }\n }\n": typeof types.SaveOnboardingDraftDocument,
"\n query TimeZoneOptions {\n timeZoneOptions {\n value\n label\n }\n }\n": typeof types.TimeZoneOptionsDocument,
"\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": typeof types.UpdateSystemTimeDocument,
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": typeof types.CreateRCloneRemoteDocument,
@@ -155,7 +156,7 @@ const documents: Documents = {
"\n subscription NotificationsWarningsAndAlertsSub {\n notificationsWarningsAndAlerts {\n ...NotificationFragment\n }\n }\n": types.NotificationsWarningsAndAlertsSubDocument,
"\n query GetAvailableLanguages {\n customization {\n availableLanguages {\n code\n name\n url\n }\n }\n }\n": types.GetAvailableLanguagesDocument,
"\n mutation BypassOnboarding {\n onboarding {\n bypassOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.BypassOnboardingDocument,
- "\n mutation CloseOnboarding {\n onboarding {\n closeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.CloseOnboardingDocument,
+ "\n mutation CloseOnboarding($input: CloseOnboardingInput) {\n onboarding {\n closeOnboarding(input: $input) {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.CloseOnboardingDocument,
"\n mutation CompleteOnboarding {\n onboarding {\n completeOnboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.CompleteOnboardingDocument,
"\n mutation UpdateServerIdentity($name: String!, $comment: String, $sysModel: String) {\n updateServerIdentity(name: $name, comment: $comment, sysModel: $sysModel) {\n id\n name\n comment\n }\n }\n": types.UpdateServerIdentityDocument,
"\n mutation SetLocale($locale: String!) {\n customization {\n setLocale(locale: $locale)\n }\n }\n": types.SetLocaleDocument,
@@ -166,12 +167,13 @@ const documents: Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n }\n }\n vars {\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n }\n }\n": types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": types.PluginInstallUpdatesDocument,
"\n mutation RefreshInternalBootContext {\n onboarding {\n refreshInternalBootContext {\n bootEligible\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n reservedNames\n shareNames\n poolNames\n driveWarnings {\n diskId\n device\n warnings\n }\n assignableDisks {\n id\n device\n size\n serialNum\n interfaceType\n }\n }\n }\n }\n": types.RefreshInternalBootContextDocument,
"\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.ResumeOnboardingDocument,
+ "\n mutation SaveOnboardingDraft($input: SaveOnboardingDraftInput!) {\n onboarding {\n saveOnboardingDraft(input: $input)\n }\n }\n": types.SaveOnboardingDraftDocument,
"\n query TimeZoneOptions {\n timeZoneOptions {\n value\n label\n }\n }\n": types.TimeZoneOptionsDocument,
"\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": types.UpdateSystemTimeDocument,
"\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": types.CreateRCloneRemoteDocument,
@@ -419,7 +421,7 @@ export function graphql(source: "\n mutation BypassOnboarding {\n onboarding
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n mutation CloseOnboarding {\n onboarding {\n closeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"): (typeof documents)["\n mutation CloseOnboarding {\n onboarding {\n closeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"];
+export function graphql(source: "\n mutation CloseOnboarding($input: CloseOnboardingInput) {\n onboarding {\n closeOnboarding(input: $input) {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"): (typeof documents)["\n mutation CloseOnboarding($input: CloseOnboardingInput) {\n onboarding {\n closeOnboarding(input: $input) {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -463,7 +465,7 @@ export function graphql(source: "\n query InstalledUnraidPlugins {\n install
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n }\n }\n vars {\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n }\n }\n vars {\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n }\n }\n"];
+export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -484,6 +486,10 @@ export function graphql(source: "\n mutation RefreshInternalBootContext {\n
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"): (typeof documents)["\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"];
+/**
+ * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
+ */
+export function graphql(source: "\n mutation SaveOnboardingDraft($input: SaveOnboardingDraftInput!) {\n onboarding {\n saveOnboardingDraft(input: $input)\n }\n }\n"): (typeof documents)["\n mutation SaveOnboardingDraft($input: SaveOnboardingDraftInput!) {\n onboarding {\n saveOnboardingDraft(input: $input)\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts
index 91e8636a79..e6b085bc5e 100644
--- a/web/src/composables/gql/graphql.ts
+++ b/web/src/composables/gql/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 */
@@ -2029,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;
@@ -2081,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'];
@@ -2603,6 +2751,12 @@ export enum Role {
VIEWER = 'VIEWER'
}
+export type SaveOnboardingDraftInput = {
+ draft?: InputMaybe;
+ internalBootState?: InputMaybe;
+ navigation?: InputMaybe;
+};
+
export type SensorConfig = {
__typename?: 'SensorConfig';
enabled?: Maybe;
@@ -3892,7 +4046,9 @@ export type BypassOnboardingMutationVariables = Exact<{ [key: string]: never; }>
export type BypassOnboardingMutation = { __typename?: 'Mutation', onboarding: { __typename?: 'OnboardingMutations', bypassOnboarding: { __typename?: 'Onboarding', status: OnboardingStatus, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean } } };
-export type CloseOnboardingMutationVariables = Exact<{ [key: string]: never; }>;
+export type CloseOnboardingMutationVariables = Exact<{
+ input?: InputMaybe;
+}>;
export type CloseOnboardingMutation = { __typename?: 'Mutation', onboarding: { __typename?: 'OnboardingMutations', closeOnboarding: { __typename?: 'Onboarding', status: OnboardingStatus, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean } } };
@@ -3965,7 +4121,7 @@ export type InstalledUnraidPluginsQuery = { __typename?: 'Query', installedUnrai
export type OnboardingBootstrapQueryVariables = Exact<{ [key: string]: never; }>;
-export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean } } } | null, vars: { __typename?: 'Vars', bootedFromFlashWithInternalBootSetup?: boolean | null, enableBootTransfer?: string | null } };
+export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean }, wizard: { __typename?: 'OnboardingWizard', currentStepId?: OnboardingWizardStepId | null, visibleStepIds: Array, draft: { __typename?: 'OnboardingWizardDraft', coreSettings?: { __typename?: 'OnboardingWizardCoreSettingsDraft', serverName?: string | null, serverDescription?: string | null, timeZone?: string | null, theme?: string | null, language?: string | null, useSsh?: boolean | null } | null, plugins?: { __typename?: 'OnboardingWizardPluginsDraft', selectedIds: Array } | null, internalBoot?: { __typename?: 'OnboardingWizardInternalBootDraft', bootMode?: OnboardingWizardBootMode | null, skipped?: boolean | null, selection?: { __typename?: 'OnboardingWizardInternalBootSelection', poolName?: string | null, slotCount?: number | null, devices: Array, bootSizeMiB?: number | null, updateBios?: boolean | null, poolMode?: OnboardingWizardPoolMode | null } | null } | null }, internalBootState: { __typename?: 'OnboardingWizardInternalBootState', applyAttempted: boolean, applySucceeded: boolean } } } } | null };
export type OpenOnboardingMutationVariables = Exact<{ [key: string]: never; }>;
@@ -3996,6 +4152,13 @@ export type ResumeOnboardingMutationVariables = Exact<{ [key: string]: never; }>
export type ResumeOnboardingMutation = { __typename?: 'Mutation', onboarding: { __typename?: 'OnboardingMutations', resumeOnboarding: { __typename?: 'Onboarding', status: OnboardingStatus, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean } } };
+export type SaveOnboardingDraftMutationVariables = Exact<{
+ input: SaveOnboardingDraftInput;
+}>;
+
+
+export type SaveOnboardingDraftMutation = { __typename?: 'Mutation', onboarding: { __typename?: 'OnboardingMutations', saveOnboardingDraft: boolean } };
+
export type TimeZoneOptionsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -4140,7 +4303,7 @@ export const NotificationOverviewSubDocument = {"kind":"Document","definitions":
export const NotificationsWarningsAndAlertsSubDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"NotificationsWarningsAndAlertsSub"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notificationsWarningsAndAlerts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode;
export const GetAvailableLanguagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetAvailableLanguages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"availableLanguages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]}}]} as unknown as DocumentNode;
export const BypassOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"BypassOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bypassOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
-export const CloseOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CloseOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"closeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const CloseOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CloseOnboarding"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CloseOnboardingInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"closeOnboarding"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
export const CompleteOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CompleteOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"completeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
export const UpdateServerIdentityDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateServerIdentity"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"name"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"comment"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sysModel"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateServerIdentity"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"name"},"value":{"kind":"Variable","name":{"kind":"Name","value":"name"}}},{"kind":"Argument","name":{"kind":"Name","value":"comment"},"value":{"kind":"Variable","name":{"kind":"Name","value":"comment"}}},{"kind":"Argument","name":{"kind":"Name","value":"sysModel"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sysModel"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}}]}}]}}]} as unknown as DocumentNode;
export const SetLocaleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetLocale"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"locale"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setLocale"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"locale"},"value":{"kind":"Variable","name":{"kind":"Name","value":"locale"}}}]}]}}]}}]} as unknown as DocumentNode;
@@ -4151,12 +4314,13 @@ export const GetInternalBootContextDocument = {"kind":"Document","definitions":[
export const InstallLanguageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallLanguage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installLanguage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstallPluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallPlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installPlugin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstalledUnraidPluginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InstalledUnraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installedUnraidPlugins"}}]}}]} as unknown as DocumentNode;
-export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootedFromFlashWithInternalBootSetup"}},{"kind":"Field","name":{"kind":"Name","value":"enableBootTransfer"}}]}}]}}]} as unknown as DocumentNode;
+export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"wizard"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentStepId"}},{"kind":"Field","name":{"kind":"Name","value":"visibleStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"draft"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"coreSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"serverDescription"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"useSsh"}}]}},{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"selectedIds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBoot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootMode"}},{"kind":"Field","name":{"kind":"Name","value":"skipped"}},{"kind":"Field","name":{"kind":"Name","value":"selection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"poolName"}},{"kind":"Field","name":{"kind":"Name","value":"slotCount"}},{"kind":"Field","name":{"kind":"Name","value":"devices"}},{"kind":"Field","name":{"kind":"Name","value":"bootSizeMiB"}},{"kind":"Field","name":{"kind":"Name","value":"updateBios"}},{"kind":"Field","name":{"kind":"Name","value":"poolMode"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBootState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyAttempted"}},{"kind":"Field","name":{"kind":"Name","value":"applySucceeded"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const OpenOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"OpenOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallOperationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PluginInstallOperation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallOperation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PluginInstallUpdates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallUpdates"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"operationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"output"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode;
export const RefreshInternalBootContextDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RefreshInternalBootContext"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshInternalBootContext"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootEligible"}},{"kind":"Field","name":{"kind":"Name","value":"bootedFromFlashWithInternalBootSetup"}},{"kind":"Field","name":{"kind":"Name","value":"enableBootTransfer"}},{"kind":"Field","name":{"kind":"Name","value":"reservedNames"}},{"kind":"Field","name":{"kind":"Name","value":"shareNames"}},{"kind":"Field","name":{"kind":"Name","value":"poolNames"}},{"kind":"Field","name":{"kind":"Name","value":"driveWarnings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"diskId"}},{"kind":"Field","name":{"kind":"Name","value":"device"}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignableDisks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"device"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"serialNum"}},{"kind":"Field","name":{"kind":"Name","value":"interfaceType"}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const ResumeOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResumeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resumeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
+export const SaveOnboardingDraftDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SaveOnboardingDraft"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SaveOnboardingDraftInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"saveOnboardingDraft"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]}}]} as unknown as DocumentNode;
export const TimeZoneOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TimeZoneOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeZoneOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]}}]} as unknown as DocumentNode;
export const UpdateSystemTimeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSystemTime"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSystemTimeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSystemTime"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentTime"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"useNtp"}},{"kind":"Field","name":{"kind":"Name","value":"ntpServers"}}]}}]}}]} as unknown as DocumentNode;
export const CreateRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRCloneRemote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}}]}}]}}]}}]} as unknown as DocumentNode;
From 6301eb2c8b0194bd33cc87b28e974b8552299cfe Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 17:27:56 -0400
Subject: [PATCH 03/52] test(onboarding): cover the server-owned wizard flow
- Purpose: lock in the new server-owned onboarding behavior with direct API and web coverage.
- Before: tests mainly covered the older completion/open flow and client-owned draft behavior, leaving the new save-on-transition path under-tested.
- Problem: without new coverage, regressions in nested draft merges, step fallback logic, and save-failure UX would be easy to miss.
- Now: the test suite verifies tracker persistence semantics, wizard visibility/current-step resolution, mutation behavior, modal save failures, and legacy-key cleanup.
Files changed in this commit:
- api/src/unraid-api/config/onboarding-tracker.service.spec.ts: added coverage for empty wizard defaults, nested partial draft merges, navigation persistence, and internal-boot state persistence.
- api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts: added wizard-payload coverage for visible-step computation, current-step fallback, and saveOnboardingDraft mapping.
- api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts: covered saveOnboardingDraft delegation and close-on-save-failure logging.
- api/src/unraid-api/graph/resolvers/customization/customization.resolver.spec.ts: updated resolver expectations so the shared onboarding response shape includes the new wizard payload.
- web/__test__/components/Onboarding/OnboardingModal.test.ts: covered server-hydrated wizard state, transition persistence, and blocked navigation with a save-failure close path.
- web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts: updated the step tests for prop-driven draft hydration and transition-time save state.
- web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts: updated plugin-step expectations for callback-driven draft snapshots and disabled/loading state during saves.
- web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts: aligned internal-boot step coverage with the new prop/callback draft flow.
- web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts: updated standalone internal-boot tests to use local draft refs instead of the removed store.
- web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts: updated summary expectations for nested draft/internal-boot props.
- web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts: kept completion/reboot/shutdown coverage working against the new modal/data flow.
- web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts: verified that legacy onboarding draft keys are deleted and not recreated by the removed store.
- web/__test__/store/onboardingContextData.test.ts: updated bootstrap-store coverage for the new wizard payload shape.
---
.../config/onboarding-tracker.service.spec.ts | 190 ++++++
.../customization.resolver.spec.ts | 32 +-
.../customization/onboarding.service.spec.ts | 143 ++++-
.../onboarding/onboarding.mutation.spec.ts | 63 +-
.../OnboardingCoreSettingsStep.test.ts | 18 +-
.../OnboardingInternalBootStandalone.test.ts | 430 +++++--------
.../OnboardingInternalBootStep.test.ts | 9 +-
.../Onboarding/OnboardingModal.test.ts | 580 +++++++++---------
.../OnboardingNextStepsStep.test.ts | 27 +-
.../Onboarding/OnboardingPluginsStep.test.ts | 38 +-
.../Onboarding/OnboardingSummaryStep.test.ts | 34 +-
.../onboardingStorageCleanup.test.ts | 23 +-
.../store/onboardingContextData.test.ts | 25 +-
13 files changed, 978 insertions(+), 634 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 26ea4091c8..923f44081b 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -18,6 +18,15 @@ vi.mock('atomically', () => ({
const mockReadFile = vi.mocked(readFile);
const mockAtomicWriteFile = vi.mocked(atomicWriteFile);
+const createEmptyWizardState = () => ({
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+});
+
const createConfigService = (dataDir = '/tmp/unraid-data') => {
const set = vi.fn();
const get = vi.fn((key: string) => {
@@ -114,6 +123,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
completed: false,
completedAtVersion: undefined,
forceOpen: false,
+ ...createEmptyWizardState(),
},
});
});
@@ -166,6 +176,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
completed: true,
completedAtVersion: '7.2.0',
forceOpen: false,
+ ...createEmptyWizardState(),
},
});
});
@@ -196,6 +207,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
completed: true,
completedAtVersion: '7.2.0',
forceOpen: false,
+ ...createEmptyWizardState(),
});
});
@@ -225,6 +237,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
completed: false,
completedAtVersion: undefined,
forceOpen: false,
+ ...createEmptyWizardState(),
});
});
@@ -261,4 +274,181 @@ describe('OnboardingTrackerService tracker state availability', () => {
await expect(tracker.getCompletedAtVersion()).rejects.toThrow('permission denied');
});
+
+ it('merges partial draft updates without wiping sibling step data', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ timeZone: 'America/New_York',
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ navigation: {
+ currentStepId: 'ADD_PLUGINS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(
+ tracker.saveDraft({
+ draft: {
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['disk1'],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'dedicated',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ })
+ ).resolves.toEqual({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ timeZone: 'America/New_York',
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['disk1'],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'dedicated',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+
+ expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1);
+ const writtenState = JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1])) as {
+ draft?: {
+ coreSettings?: { serverName?: string };
+ internalBoot?: { selection?: { poolName?: string } };
+ };
+ navigation?: { currentStepId?: string };
+ };
+ expect(writtenState.draft?.coreSettings?.serverName).toBe('Tower');
+ expect(writtenState.navigation?.currentStepId).toBe('SUMMARY');
+ expect(writtenState.draft?.internalBoot?.selection?.poolName).toBe('cache');
+ });
+
+ it('persists internal boot status updates while preserving existing draft state', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: ['disk1', 'disk2'],
+ bootSizeMiB: 32768,
+ updateBios: false,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(
+ tracker.saveDraft({
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ })
+ ).resolves.toEqual({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: ['disk1', 'disk2'],
+ bootSizeMiB: 32768,
+ updateBios: false,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ });
+ });
});
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 6e1d2a8dec..ea38accfeb 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
@@ -1,11 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
+import {
+ OnboardingStatus,
+ OnboardingWizardStepId,
+} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
import { CustomizationResolver } from '@app/unraid-api/graph/resolvers/customization/customization.resolver.js';
import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js';
import { DisplayService } from '@app/unraid-api/graph/resolvers/info/display/display.service.js';
describe('CustomizationResolver', () => {
+ const emptyWizardState = {
+ currentStepId: OnboardingWizardStepId.OVERVIEW,
+ visibleStepIds: [
+ OnboardingWizardStepId.OVERVIEW,
+ OnboardingWizardStepId.CONFIGURE_SETTINGS,
+ OnboardingWizardStepId.ADD_PLUGINS,
+ OnboardingWizardStepId.SUMMARY,
+ OnboardingWizardStepId.NEXT_STEPS,
+ ],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
const onboardingService = {
getActivationData: vi.fn(),
getActivationDataForPublic: vi.fn(),
@@ -38,6 +56,7 @@ describe('CustomizationResolver', () => {
completed: false,
completedAtVersion: undefined,
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -63,6 +82,7 @@ describe('CustomizationResolver', () => {
completed: false,
completedAtVersion: undefined,
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -80,6 +100,7 @@ describe('CustomizationResolver', () => {
completed: false,
completedAtVersion: undefined,
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -97,6 +118,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.2.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -114,6 +136,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.2.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -131,6 +154,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.2.1',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -148,6 +172,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.2.1',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -165,6 +190,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.1.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -182,6 +208,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.1.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -199,6 +226,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.3.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -216,6 +244,7 @@ describe('CustomizationResolver', () => {
completed: true,
completedAtVersion: '7.3.0',
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
@@ -233,6 +262,7 @@ describe('CustomizationResolver', () => {
completed: false,
completedAtVersion: undefined,
shouldOpen: false,
+ wizard: emptyWizardState,
onboardingState: {
registrationState: undefined,
isRegistered: false,
diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
index 56f98d4b5d..620dac0548 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
@@ -17,6 +17,9 @@ import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-trac
import {
ActivationCode,
OnboardingStatus,
+ OnboardingWizardBootMode,
+ OnboardingWizardPoolMode,
+ OnboardingWizardStepId,
} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js';
@@ -134,6 +137,7 @@ const onboardingTrackerMock = {
) => Promise<{ completed: boolean; completedAtVersion?: string; forceOpen: boolean }>
>(),
setBypassActive: vi.fn<(active: boolean) => void>(),
+ saveDraft: vi.fn(),
};
const onboardingOverridesMock = {
getState: vi.fn(),
@@ -206,6 +210,12 @@ describe('OnboardingService', () => {
completed: false,
completedAtVersion: undefined,
forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
},
});
onboardingTrackerMock.getCurrentVersion.mockReset();
@@ -231,6 +241,18 @@ describe('OnboardingService', () => {
forceOpen,
}));
onboardingTrackerMock.setBypassActive.mockReset();
+ onboardingTrackerMock.saveDraft.mockReset();
+ onboardingTrackerMock.saveDraft.mockResolvedValue({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
onboardingOverridesMock.getState.mockReset();
onboardingOverridesMock.getState.mockReturnValue(null);
onboardingOverridesMock.setState.mockReset();
@@ -320,7 +342,7 @@ describe('OnboardingService', () => {
await expect(
service.getOnboardingResponse({ includeActivationCode: true })
- ).resolves.toEqual({
+ ).resolves.toMatchObject({
status: OnboardingStatus.COMPLETED,
isPartnerBuild: true,
completed: true,
@@ -334,6 +356,9 @@ describe('OnboardingService', () => {
hasActivationCode: true,
activationRequired: false,
},
+ wizard: {
+ currentStepId: OnboardingWizardStepId.OVERVIEW,
+ },
});
});
@@ -1845,4 +1870,120 @@ describe('OnboardingService - updateCfgFile', () => {
expect((await service.getTheme()).showBannerGradient).toBe(false);
});
});
+
+ describe('wizard state', () => {
+ const mockEmhttp = getters.emhttp as unknown as Mock;
+
+ it('returns live-computed visible steps and falls forward when the saved current step is hidden', async () => {
+ mockEmhttp.mockReturnValue({
+ var: {
+ name: 'Tower',
+ sysModel: 'Custom',
+ comment: 'Default',
+ enableBootTransfer: 'no',
+ },
+ });
+ onboardingTrackerMock.getStateResult.mockResolvedValue({
+ kind: 'ok',
+ state: {
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ navigation: {
+ currentStepId: 'CONFIGURE_BOOT',
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: false,
+ },
+ },
+ });
+ onboardingTrackerMock.getCurrentVersion.mockReturnValue('7.3.0');
+ onboardingStateMock.getRegistrationState.mockReturnValue('PRO');
+ onboardingStateMock.isRegistered.mockReturnValue(true);
+ onboardingStateMock.isFreshInstall.mockReturnValue(true);
+ onboardingStateMock.hasActivationCode.mockResolvedValue(false);
+ onboardingStateMock.requiresActivationStep.mockReturnValue(false);
+ vi.spyOn(service, 'getPublicPartnerInfo').mockResolvedValue(null);
+
+ const response = await service.getOnboardingResponse();
+
+ expect(response.status).toBe(OnboardingStatus.INCOMPLETE);
+ expect(response.wizard.visibleStepIds).toEqual([
+ OnboardingWizardStepId.OVERVIEW,
+ OnboardingWizardStepId.CONFIGURE_SETTINGS,
+ OnboardingWizardStepId.ADD_PLUGINS,
+ OnboardingWizardStepId.SUMMARY,
+ OnboardingWizardStepId.NEXT_STEPS,
+ ]);
+ expect(response.wizard.currentStepId).toBe(OnboardingWizardStepId.ADD_PLUGINS);
+ expect(response.wizard.internalBootState).toEqual({
+ applyAttempted: true,
+ applySucceeded: false,
+ });
+ });
+
+ it('persists nested wizard draft input in tracker format', async () => {
+ await service.saveOnboardingDraft({
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ timeZone: 'America/New_York',
+ },
+ internalBoot: {
+ bootMode: OnboardingWizardBootMode.STORAGE,
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: ['disk1', 'disk2'],
+ bootSizeMiB: 32768,
+ updateBios: true,
+ poolMode: OnboardingWizardPoolMode.HYBRID,
+ },
+ },
+ },
+ navigation: {
+ currentStepId: OnboardingWizardStepId.SUMMARY,
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ });
+
+ expect(onboardingTrackerMock.saveDraft).toHaveBeenCalledWith({
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ timeZone: 'America/New_York',
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: ['disk1', 'disk2'],
+ bootSizeMiB: 32768,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: OnboardingWizardStepId.SUMMARY,
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ });
+ });
+ });
});
diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
index bf69028236..2e09713b63 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
@@ -3,9 +3,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { OnboardingOverrideService } from '@app/unraid-api/config/onboarding-override.service.js';
import type { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js';
import type { OnboardingInternalBootService } from '@app/unraid-api/graph/resolvers/onboarding/onboarding-internal-boot.service.js';
-import type { OnboardingOverrideInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
-import { OnboardingStatus } from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
-import { CreateInternalBootPoolInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
+import type {
+ OnboardingOverrideInput,
+ SaveOnboardingDraftInput,
+} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
+import {
+ OnboardingStatus,
+ OnboardingWizardStepId,
+} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
+import {
+ CloseOnboardingReason,
+ CreateInternalBootPoolInput,
+} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js';
describe('OnboardingMutationsResolver', () => {
@@ -21,6 +30,7 @@ describe('OnboardingMutationsResolver', () => {
closeOnboarding: vi.fn(),
bypassOnboarding: vi.fn(),
resumeOnboarding: vi.fn(),
+ saveOnboardingDraft: vi.fn(),
getOnboardingResponse: vi.fn(),
clearActivationDataCache: vi.fn(),
} satisfies Pick<
@@ -31,6 +41,7 @@ describe('OnboardingMutationsResolver', () => {
| 'closeOnboarding'
| 'bypassOnboarding'
| 'resumeOnboarding'
+ | 'saveOnboardingDraft'
| 'getOnboardingResponse'
| 'clearActivationDataCache'
>;
@@ -56,6 +67,15 @@ describe('OnboardingMutationsResolver', () => {
hasActivationCode: false,
activationRequired: false,
},
+ wizard: {
+ currentStepId: 'OVERVIEW',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ },
};
let resolver: OnboardingMutationsResolver;
@@ -75,6 +95,7 @@ describe('OnboardingMutationsResolver', () => {
onboardingService.closeOnboarding.mockResolvedValue(undefined);
onboardingService.bypassOnboarding.mockResolvedValue(undefined);
onboardingService.resumeOnboarding.mockResolvedValue(undefined);
+ onboardingService.saveOnboardingDraft.mockResolvedValue(undefined);
onboardingService.getOnboardingResponse.mockResolvedValue(defaultOnboardingResponse);
resolver = createResolver();
@@ -139,6 +160,19 @@ describe('OnboardingMutationsResolver', () => {
expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith();
});
+ it('logs save-failure close reasons before delegating closeOnboarding', async () => {
+ const loggerWarn = vi.fn();
+ (resolver as unknown as { logger: { warn: (message: string) => void } }).logger.warn =
+ loggerWarn;
+
+ await expect(
+ resolver.closeOnboarding({ reason: CloseOnboardingReason.SAVE_FAILURE })
+ ).resolves.toEqual(defaultOnboardingResponse);
+
+ expect(loggerWarn).toHaveBeenCalledWith('closeOnboarding invoked with reason=SAVE_FAILURE');
+ expect(onboardingService.closeOnboarding).toHaveBeenCalledTimes(1);
+ });
+
it('delegates bypassOnboarding through the onboarding service', async () => {
const response = {
...defaultOnboardingResponse,
@@ -199,6 +233,29 @@ describe('OnboardingMutationsResolver', () => {
expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith();
});
+ it('delegates saveOnboardingDraft through the onboarding service', async () => {
+ const input: SaveOnboardingDraftInput = {
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ navigation: {
+ currentStepId: OnboardingWizardStepId.ADD_PLUGINS,
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: false,
+ },
+ };
+
+ await expect(resolver.saveOnboardingDraft(input)).resolves.toBe(true);
+ expect(onboardingService.saveOnboardingDraft).toHaveBeenCalledWith(input);
+ });
+
it('propagates onboarding response failures after completion', async () => {
onboardingService.getOnboardingResponse.mockRejectedValue(new Error('tracker-read-failed'));
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index c8c689566c..29fcff7c71 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -88,10 +88,6 @@ vi.mock('@vvo/tzdb', () => ({
],
}));
-vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => draftStore,
-}));
-
vi.mock('@/components/Onboarding/store/onboardingStatus', () => ({
useOnboardingStore: () => onboardingStore,
}));
@@ -134,9 +130,19 @@ const setupQueryMocks = () => {
};
const mountComponent = (props: Record = {}) => {
- const onComplete = vi.fn();
+ const onComplete = setCoreSettingsMock;
const wrapper = mount(OnboardingCoreSettingsStep, {
props: {
+ initialDraft: draftStore.coreSettingsInitialized
+ ? {
+ serverName: draftStore.serverName,
+ serverDescription: draftStore.serverDescription,
+ timeZone: draftStore.selectedTimeZone,
+ theme: draftStore.selectedTheme,
+ language: draftStore.selectedLanguage,
+ useSsh: draftStore.useSsh,
+ }
+ : null,
onComplete,
showBack: true,
...props,
@@ -235,6 +241,8 @@ describe('OnboardingCoreSettingsStep', () => {
it('prefers non-empty draft timezone over browser and API on initial setup', async () => {
onboardingStore.completed.value = false;
+ draftStore.coreSettingsInitialized = true;
+ draftStore.serverName = 'Tower';
draftStore.selectedTimeZone = 'UTC';
const dateTimeFormatSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
index 88cf46559a..612b636f0c 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
@@ -1,4 +1,3 @@
-import { reactive } from 'vue';
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -8,6 +7,7 @@ import type {
InternalBootApplyResult,
InternalBootSelection,
} from '~/components/Onboarding/composables/internalBoot';
+import type { OnboardingInternalBootDraft } from '~/components/Onboarding/onboardingWizardState';
import OnboardingInternalBootStandalone from '~/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue';
import { createTestI18n } from '../../utils/i18n';
@@ -21,8 +21,7 @@ type InternalBootHistoryState = {
};
const {
- draftStore,
- reactiveStoreRef,
+ configureDraftState,
applyInternalBootSelectionMock,
submitInternalBootRebootMock,
submitInternalBootShutdownMock,
@@ -30,53 +29,28 @@ const {
dialogPropsRef,
stepPropsRef,
stepperPropsRef,
-} = vi.hoisted(() => {
- const reactiveRef: { value: Record | null } = { value: null };
- const store = {
- internalBootSelection: null as {
- poolName: string;
- slotCount: number;
- devices: string[];
- bootSizeMiB: number;
- updateBios: boolean;
- poolMode: 'dedicated' | 'hybrid';
- } | null,
- internalBootApplySucceeded: false,
- internalBootApplyAttempted: false,
- setInternalBootApplySucceeded: vi.fn((value: boolean) => {
- if (reactiveRef.value) {
- reactiveRef.value.internalBootApplySucceeded = value;
- } else {
- store.internalBootApplySucceeded = value;
- }
- }),
- setInternalBootApplyAttempted: vi.fn((value: boolean) => {
- if (reactiveRef.value) {
- reactiveRef.value.internalBootApplyAttempted = value;
- } else {
- store.internalBootApplyAttempted = value;
- }
- }),
- };
-
- return {
- draftStore: store,
- reactiveStoreRef: reactiveRef,
- submitInternalBootRebootMock: vi.fn(),
- submitInternalBootShutdownMock: vi.fn(),
- applyInternalBootSelectionMock:
- vi.fn<
- (
- selection: InternalBootSelection,
- messages: InternalBootApplyMessages
- ) => Promise
- >(),
- cleanupOnboardingStorageMock: vi.fn(),
- dialogPropsRef: { value: null as Record | null },
- stepPropsRef: { value: null as Record | null },
- stepperPropsRef: { value: null as Record | null },
- };
-});
+} = vi.hoisted(() => ({
+ configureDraftState: {
+ value: {
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ } as OnboardingInternalBootDraft,
+ },
+ submitInternalBootRebootMock: vi.fn(),
+ submitInternalBootShutdownMock: vi.fn(),
+ applyInternalBootSelectionMock:
+ vi.fn<
+ (
+ selection: InternalBootSelection,
+ messages: InternalBootApplyMessages
+ ) => Promise
+ >(),
+ cleanupOnboardingStorageMock: vi.fn(),
+ dialogPropsRef: { value: null as Record | null },
+ stepPropsRef: { value: null as Record | null },
+ stepperPropsRef: { value: null as Record | null },
+}));
vi.mock('@unraid/ui', () => ({
Dialog: {
@@ -87,7 +61,7 @@ vi.mock('@unraid/ui', () => ({
return { props };
},
template: `
-
+
Dismiss
@@ -95,13 +69,6 @@ vi.mock('@unraid/ui', () => ({
},
}));
-const reactiveDraftStore = reactive(draftStore);
-reactiveStoreRef.value = reactiveDraftStore;
-
-vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => reactiveDraftStore,
-}));
-
vi.mock('@/components/Onboarding/composables/internalBoot', () => ({
applyInternalBootSelection: applyInternalBootSelectionMock,
submitInternalBootReboot: submitInternalBootRebootMock,
@@ -126,24 +93,41 @@ vi.mock('@/components/Onboarding/OnboardingSteps.vue', () => ({
stepperPropsRef.value = props;
return { props };
},
- template: `
-
- {{ props.activeStepIndex }}
-
- `,
+ template: '
{{ props.activeStepIndex }}
',
},
}));
vi.mock('@/components/Onboarding/steps/OnboardingInternalBootStep.vue', () => ({
default: {
- props: ['onComplete', 'showBack', 'showSkip', 'isSavingStep'],
+ props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'showSkip', 'isSavingStep'],
setup(props: Record
) {
+ const cloneDraft = () => {
+ const initialDraft = configureDraftState.value;
+ return {
+ bootMode: initialDraft?.bootMode ?? 'usb',
+ skipped: initialDraft?.skipped ?? true,
+ selection:
+ initialDraft?.selection === undefined
+ ? undefined
+ : initialDraft.selection === null
+ ? null
+ : {
+ ...initialDraft.selection,
+ devices: [...(initialDraft.selection.devices ?? [])],
+ },
+ };
+ };
stepPropsRef.value = props;
- return { props };
+ return { props, cloneDraft };
},
template: `
- Complete
+
+ Complete
+
`,
},
@@ -202,15 +186,33 @@ const dispatchPopstate = (state: Record | null) => {
window.dispatchEvent(new PopStateEvent('popstate', { state }));
};
+const findButtonByText = (wrapper: ReturnType, text: string) =>
+ wrapper
+ .findAll('button')
+ .find((button) => button.text().trim().toLowerCase() === text.trim().toLowerCase());
+
+const advanceToSummary = async (wrapper: ReturnType) => {
+ await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
+ await flushPromises();
+};
+
+const confirmAndApply = async (wrapper: ReturnType) => {
+ const confirmButton = findButtonByText(wrapper, 'Confirm & Apply');
+ expect(confirmButton).toBeTruthy();
+ await confirmButton!.trigger('click');
+ await flushPromises();
+};
+
describe('OnboardingInternalBoot.standalone.vue', () => {
beforeEach(() => {
- vi.useFakeTimers();
vi.clearAllMocks();
window.history.replaceState(null, '', window.location.href);
- reactiveDraftStore.internalBootSelection = null;
- reactiveDraftStore.internalBootApplySucceeded = false;
- reactiveDraftStore.internalBootApplyAttempted = false;
+ configureDraftState.value = {
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ };
dialogPropsRef.value = null;
stepPropsRef.value = null;
stepperPropsRef.value = null;
@@ -239,6 +241,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
showBack: false,
showSkip: false,
isSavingStep: false,
+ initialDraft: configureDraftState.value,
});
expect(stepperPropsRef.value).toMatchObject({
activeStepIndex: 0,
@@ -252,11 +255,10 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
it('treats no selection as a no-op success without calling apply helper', async () => {
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
- expect(draftStore.setInternalBootApplySucceeded).toHaveBeenCalledWith(false);
expect(wrapper.text()).toContain('No Updates Needed');
expect(wrapper.text()).toContain('No changes needed. Skipping configuration updates.');
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true);
@@ -266,30 +268,29 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
it('applies the selected internal boot configuration and records success', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: true,
- poolMode: 'hybrid',
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['DISK-A'],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: true,
hadWarnings: false,
hadNonOptimisticFailures: false,
- logs: [
- {
- message: 'Internal boot pool configured.',
- type: 'success',
- },
- ],
+ logs: [{ message: 'Internal boot pool configured.', type: 'success' }],
});
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
expect(applyInternalBootSelectionMock).toHaveBeenCalledWith(
{
@@ -307,42 +308,40 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
biosUnverified: expect.any(String),
}
);
- expect(draftStore.setInternalBootApplySucceeded).toHaveBeenNthCalledWith(1, false);
- expect(draftStore.setInternalBootApplySucceeded).toHaveBeenNthCalledWith(2, true);
expect(wrapper.find('[data-testid="onboarding-console"]').exists()).toBe(true);
expect(wrapper.text()).toContain('Internal boot pool configured.');
expect(wrapper.text()).toContain('Setup Applied');
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true);
expect(stepperPropsRef.value).toMatchObject({
activeStepIndex: 1,
});
});
- it('shows locked failure result with reboot button when apply fails', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: false,
- poolMode: 'hybrid',
+ it('shows a locked failure result with reboot controls when apply fails', async () => {
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['DISK-A'],
+ bootSizeMiB: 16384,
+ updateBios: false,
+ poolMode: 'hybrid',
+ },
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: false,
hadWarnings: true,
hadNonOptimisticFailures: true,
- logs: [
- {
- message: 'Internal boot setup returned an error: mkbootpool failed',
- type: 'error',
- },
- ],
+ logs: [{ message: 'Internal boot setup returned an error: mkbootpool failed', type: 'error' }],
});
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
expect(wrapper.text()).toContain('Setup Failed');
expect(wrapper.text()).toContain('Internal boot setup returned an error: mkbootpool failed');
@@ -353,8 +352,8 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
it('restores the configure step when browser back leaves a reversible result', async () => {
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
const currentHistoryState = getInternalBootHistoryState();
expect(currentHistoryState).toMatchObject({
@@ -370,7 +369,6 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
},
});
await flushPromises();
- await wrapper.vm.$nextTick();
expect(stepperPropsRef.value).toMatchObject({
activeStepIndex: 0,
@@ -380,19 +378,23 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
it('blocks browser back navigation when locked after a fully applied result', async () => {
const forwardSpy = vi.spyOn(window.history, 'forward').mockImplementation(() => {});
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: true,
- poolMode: 'hybrid',
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['DISK-A'],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
};
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
const currentHistoryState = getInternalBootHistoryState();
expect(currentHistoryState).toMatchObject({
@@ -414,12 +416,12 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(true);
});
- it('closes locally after showing a result', async () => {
+ it('closes locally after showing a reversible result', async () => {
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click');
await flushPromises();
@@ -428,24 +430,11 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
dispatchPopstate(null);
await flushPromises();
- expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(false);
- });
-
- it('closes when the shared dialog requests dismissal', async () => {
- const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
- const wrapper = mountComponent();
-
- await wrapper.get('[data-testid="dialog-dismiss"]').trigger('click');
- await flushPromises();
-
- expect(historyGoSpy).toHaveBeenCalledWith(-1);
- dispatchPopstate(null);
- await flushPromises();
-
+ expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
});
- it('closes via the top-right X button', async () => {
+ it('closes via the top-right X button in edit mode', async () => {
const historyGoSpy = vi.spyOn(window.history, 'go').mockImplementation(() => {});
const wrapper = mountComponent();
@@ -460,165 +449,56 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
});
- it('shows warning result when apply succeeds with warnings', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'boot-pool',
- slotCount: 1,
- devices: ['sda'],
- bootSizeMiB: 512,
- updateBios: true,
- poolMode: 'hybrid',
- };
- applyInternalBootSelectionMock.mockResolvedValue({
- applySucceeded: true,
- hadWarnings: true,
- hadNonOptimisticFailures: true,
- logs: [
- { message: 'Boot configured.', type: 'success' as const },
- { message: 'BIOS update completed with warnings', type: 'error' as const },
- ],
- });
-
- const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
-
- expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(true);
- expect(wrapper.text()).toContain('Setup Applied with Warnings');
- expect(wrapper.find('[data-testid="warning-icon"]').exists()).toBe(true);
- });
-
- it('clears onboarding storage when closing after a successful result', async () => {
- vi.spyOn(window.history, 'go').mockImplementation(() => {});
- const wrapper = mountComponent();
-
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
-
- expect(wrapper.find('[data-testid="internal-boot-standalone-result"]').exists()).toBe(true);
-
- await wrapper.get('[data-testid="internal-boot-standalone-result-close"]').trigger('click');
- await flushPromises();
-
- dispatchPopstate(null);
- await flushPromises();
-
- expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
- });
-
- it('hides the X button when internalBootApplyAttempted is true', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: false,
- poolMode: 'hybrid',
+ it('shows reboot and shutdown actions when the result is locked', async () => {
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['DISK-A'],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
};
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
- expect(draftStore.internalBootApplyAttempted).toBe(true);
expect(wrapper.find('[data-testid="internal-boot-standalone-close"]').exists()).toBe(false);
- });
-
- it('hides "Edit Again" button when locked after apply', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: false,
- poolMode: 'hybrid',
- };
- applyInternalBootSelectionMock.mockResolvedValue({
- applySucceeded: false,
- hadWarnings: true,
- hadNonOptimisticFailures: true,
- logs: [{ message: 'Setup failed', type: 'error' }],
- });
-
- const wrapper = mountComponent();
-
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
-
- expect(draftStore.internalBootApplyAttempted).toBe(true);
- expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(false);
- });
-
- it('shows "Reboot" button instead of "Close" when locked', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: true,
- poolMode: 'hybrid',
- };
-
- const wrapper = mountComponent();
-
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
-
- expect(draftStore.internalBootApplyAttempted).toBe(true);
expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(true);
});
- it('calls submitInternalBootReboot directly when reboot is clicked', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: true,
- poolMode: 'hybrid',
+ it('calls reboot and shutdown helpers from the locked result actions', async () => {
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: ['DISK-A'],
+ bootSizeMiB: 16384,
+ updateBios: false,
+ poolMode: 'hybrid',
+ },
};
const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
- await flushPromises();
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
await wrapper.get('[data-testid="internal-boot-standalone-reboot"]').trigger('click');
await flushPromises();
-
expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1);
- });
-
- it('shows shutdown button when locked and calls submitInternalBootShutdown directly', async () => {
- reactiveDraftStore.internalBootSelection = {
- poolName: 'cache',
- slotCount: 1,
- devices: ['DISK-A'],
- bootSizeMiB: 16384,
- updateBios: false,
- poolMode: 'hybrid',
- };
-
- const wrapper = mountComponent();
- await wrapper.get('[data-testid="internal-boot-step-complete"]').trigger('click');
+ await wrapper.get('[data-testid="internal-boot-standalone-shutdown"]').trigger('click');
await flushPromises();
-
- const shutdownButton = wrapper.find('[data-testid="internal-boot-standalone-shutdown"]');
- expect(shutdownButton.exists()).toBe(true);
-
- await shutdownButton.trigger('click');
- await flushPromises();
-
expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1);
- expect(submitInternalBootRebootMock).not.toHaveBeenCalled();
- });
-
- it('does not show shutdown button when not locked', () => {
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(false);
});
});
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 7259d0f41e..2c38ad038d 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -98,10 +98,6 @@ useMutationMock.mockImplementation((document: unknown) => {
};
});
-vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => draftStore,
-}));
-
const gib = (value: number) => value * 1024 * 1024 * 1024;
const buildContext = (
@@ -123,6 +119,11 @@ const buildContext = (
const mountComponent = () =>
mount(OnboardingInternalBootStep, {
props: {
+ initialDraft: {
+ bootMode: draftStore.bootMode,
+ skipped: draftStore.bootMode !== 'storage',
+ selection: draftStore.internalBootSelection,
+ },
onComplete: vi.fn(),
showBack: true,
},
diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts
index 3bf1449340..109a40800e 100644
--- a/web/__test__/components/Onboarding/OnboardingModal.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts
@@ -1,70 +1,51 @@
-import { ref } from 'vue';
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import type { StepId } from '~/components/Onboarding/stepRegistry.js';
+import type { OnboardingWizardDraft } from '~/components/Onboarding/onboardingWizardState';
+import type { StepId } from '~/components/Onboarding/stepRegistry';
+import { SAVE_ONBOARDING_DRAFT_MUTATION } from '~/components/Onboarding/graphql/saveOnboardingDraft.mutation';
import OnboardingModal from '~/components/Onboarding/OnboardingModal.vue';
import { createTestI18n } from '../../utils/i18n';
-type InternalBootVisibilityResult = {
- value: {
- bootedFromFlashWithInternalBootSetup: boolean | null;
- enableBootTransfer: string | null;
- } | null;
-};
-
const {
- internalBootVisibilityResult,
- internalBootVisibilityLoading,
+ wizardRef,
+ onboardingContextLoading,
onboardingModalStoreState,
activationCodeDataStore,
onboardingStatusStore,
- onboardingDraftStore,
purchaseStore,
serverStore,
themeStore,
+ saveOnboardingDraftMock,
cleanupOnboardingStorageMock,
} = vi.hoisted(() => ({
- internalBootVisibilityResult: {
+ wizardRef: {
value: {
- bootedFromFlashWithInternalBootSetup: false,
- enableBootTransfer: 'yes',
+ currentStepId: 'OVERVIEW' as StepId,
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'],
+ draft: {} as OnboardingWizardDraft,
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
},
- } as InternalBootVisibilityResult,
- internalBootVisibilityLoading: { value: false },
+ },
+ onboardingContextLoading: { value: false },
onboardingModalStoreState: {
isVisible: { value: true },
sessionSource: { value: 'automatic' as 'automatic' | 'manual' },
closeModal: vi.fn().mockResolvedValue(true),
},
activationCodeDataStore: {
- loading: { value: false },
activationRequired: { value: false },
hasActivationCode: { value: true },
- registrationState: { value: 'ENOKEYFILE' as string | null },
- partnerInfo: {
- value: {
- partner: { name: null, url: null },
- branding: { hasPartnerLogo: false },
- },
- },
},
onboardingStatusStore: {
isVersionDrift: { value: false },
completedAtVersion: { value: null as string | null },
canDisplayOnboardingModal: { value: true },
- isPartnerBuild: { value: false },
- refetchOnboarding: vi.fn().mockResolvedValue(undefined),
- },
- onboardingDraftStore: {
- currentStepId: { value: null as StepId | null },
- internalBootApplySucceeded: { value: false },
- internalBootApplyAttempted: { value: false },
- setCurrentStep: vi.fn((stepId: StepId) => {
- onboardingDraftStore.currentStepId.value = stepId;
- }),
},
purchaseStore: {
generateUrl: vi.fn(() => 'https://example.com/activate'),
@@ -76,6 +57,7 @@ const {
themeStore: {
fetchTheme: vi.fn().mockResolvedValue(undefined),
},
+ saveOnboardingDraftMock: vi.fn(),
cleanupOnboardingStorageMock: vi.fn(),
}));
@@ -94,10 +76,6 @@ vi.mock('@unraid/ui', () => ({
emits: ['update:modelValue'],
template: '
',
},
- Spinner: {
- name: 'Spinner',
- template: '
',
- },
}));
vi.mock('@heroicons/vue/24/solid', () => ({
@@ -105,34 +83,84 @@ vi.mock('@heroicons/vue/24/solid', () => ({
XMarkIcon: { template: ' ' },
}));
+vi.mock('~/components/Onboarding/components/OnboardingLoadingState.vue', () => ({
+ default: {
+ props: ['title', 'description'],
+ template:
+ '{{ title }}
{{ description }}
',
+ },
+}));
+
vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({
default: {
props: ['steps', 'activeStepIndex'],
- template: '
',
+ template:
+ '{{ steps.map((step) => step.id).join(",") }}|{{ activeStepIndex }}
',
},
}));
vi.mock('~/components/Onboarding/stepRegistry', () => ({
+ STEP_IDS: [
+ 'OVERVIEW',
+ 'CONFIGURE_SETTINGS',
+ 'CONFIGURE_BOOT',
+ 'ADD_PLUGINS',
+ 'ACTIVATE_LICENSE',
+ 'SUMMARY',
+ 'NEXT_STEPS',
+ ],
stepComponents: {
OVERVIEW: {
- props: ['onComplete', 'onBack', 'showBack'],
+ props: ['onComplete', 'onSkipSetup'],
template:
- 'next back
',
+ 'next skip
',
},
CONFIGURE_SETTINGS: {
- props: ['onComplete', 'onBack', 'showBack'],
- template:
- 'next back
',
+ props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'isSavingStep', 'saveError'],
+ template: `
+
+
{{ saveError }}
+
next
+
back
+
+ `,
},
CONFIGURE_BOOT: {
- props: ['onComplete', 'onBack', 'showBack'],
- template:
- 'next back
',
+ props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'saveError'],
+ template: `
+
+
{{ saveError }}
+
+ next
+
+
back
+
+ `,
},
ADD_PLUGINS: {
- props: ['onComplete', 'onBack', 'showBack'],
- template:
- 'next back
',
+ props: ['initialDraft', 'onComplete', 'onSkip', 'onBack', 'showBack', 'saveError'],
+ template: `
+
+
{{ saveError }}
+
next
+
skip
+
back
+
+ `,
},
ACTIVATE_LICENSE: {
props: ['onComplete', 'onBack', 'showBack'],
@@ -140,25 +168,30 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
'next back
',
},
SUMMARY: {
- props: ['onComplete', 'onBack', 'showBack'],
- template:
- 'next back
',
+ props: [
+ 'draft',
+ 'internalBootState',
+ 'onInternalBootStateChange',
+ 'onComplete',
+ 'onBack',
+ 'showBack',
+ ],
+ template: `
+
+ lock
+ next
+ back
+
+ `,
},
NEXT_STEPS: {
- props: ['onComplete', 'onBack', 'showBack'],
- setup(props: { onComplete: () => void; onBack?: () => void; showBack?: boolean }) {
- const handleClick = () => {
- cleanupOnboardingStorageMock();
- props.onComplete();
- };
-
- return {
- handleClick,
- props,
- };
- },
- template:
- 'finish back
',
+ props: ['draft', 'internalBootState', 'onComplete', 'onBack', 'showBack'],
+ template: `
+
+ finish
+ back
+
+ `,
},
},
}));
@@ -173,8 +206,8 @@ vi.mock('~/components/Onboarding/store/activationCodeData', () => ({
vi.mock('~/components/Onboarding/store/onboardingContextData', () => ({
useOnboardingContextDataStore: () => ({
- internalBootVisibility: internalBootVisibilityResult,
- loading: internalBootVisibilityLoading,
+ wizard: wizardRef,
+ loading: onboardingContextLoading,
}),
}));
@@ -182,10 +215,6 @@ vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({
useOnboardingStore: () => onboardingStatusStore,
}));
-vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => onboardingDraftStore,
-}));
-
vi.mock('~/store/purchase', () => ({
usePurchaseStore: () => purchaseStore,
}));
@@ -202,286 +231,269 @@ vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({
cleanupOnboardingStorage: cleanupOnboardingStorageMock,
}));
+vi.mock('@vue/apollo-composable', async () => {
+ const actual =
+ await vi.importActual('@vue/apollo-composable');
+ return {
+ ...actual,
+ useMutation: (document: unknown) => {
+ if (document === SAVE_ONBOARDING_DRAFT_MUTATION) {
+ return {
+ mutate: saveOnboardingDraftMock,
+ };
+ }
+
+ return {
+ mutate: vi.fn(),
+ };
+ },
+ };
+});
+
+const mountComponent = () =>
+ mount(OnboardingModal, {
+ global: {
+ plugins: [createTestI18n()],
+ },
+ });
+
+const findButtonByText = (wrapper: ReturnType, text: string) =>
+ wrapper.findAll('button').find((button) => button.text().trim().toLowerCase() === text.toLowerCase());
+
describe('OnboardingModal.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
-
- onboardingModalStoreState.closeModal.mockImplementation(async () => {
- onboardingModalStoreState.isVisible.value = false;
- onboardingModalStoreState.sessionSource.value = 'automatic';
- return true;
- });
-
- activationCodeDataStore.loading = ref(false);
- activationCodeDataStore.activationRequired = ref(false);
- activationCodeDataStore.hasActivationCode = ref(true);
- activationCodeDataStore.registrationState = ref('ENOKEYFILE');
onboardingModalStoreState.isVisible.value = true;
onboardingModalStoreState.sessionSource.value = 'automatic';
- activationCodeDataStore.registrationState.value = 'ENOKEYFILE';
- onboardingStatusStore.isVersionDrift.value = false;
- onboardingStatusStore.completedAtVersion.value = null;
onboardingStatusStore.canDisplayOnboardingModal.value = true;
- onboardingStatusStore.isPartnerBuild.value = false;
- onboardingDraftStore.currentStepId.value = null;
- onboardingDraftStore.internalBootApplySucceeded.value = false;
- onboardingDraftStore.internalBootApplyAttempted.value = false;
- internalBootVisibilityLoading.value = false;
- internalBootVisibilityResult.value = {
- bootedFromFlashWithInternalBootSetup: false,
- enableBootTransfer: 'yes',
+ onboardingContextLoading.value = false;
+ wizardRef.value = {
+ currentStepId: 'OVERVIEW',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY', 'NEXT_STEPS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
};
- });
-
- const mountComponent = () =>
- mount(OnboardingModal, {
- global: {
- plugins: [createTestI18n()],
+ saveOnboardingDraftMock.mockResolvedValue({
+ data: {
+ onboarding: {
+ saveOnboardingDraft: true,
+ },
},
});
-
- it.each([
- ['OVERVIEW', 'overview-step'],
- ['CONFIGURE_SETTINGS', 'settings-step'],
- ['CONFIGURE_BOOT', 'internal-boot-step'],
- ['ADD_PLUGINS', 'plugins-step'],
- ['ACTIVATE_LICENSE', 'license-step'],
- ['SUMMARY', 'summary-step'],
- ['NEXT_STEPS', 'next-step'],
- ] as const)('resumes persisted step %s when it is available', (stepId, testId) => {
- onboardingDraftStore.currentStepId.value = stepId;
-
- const wrapper = mountComponent();
-
- expect(wrapper.find(`[data-testid="${testId}"]`).exists()).toBe(true);
- });
-
- it('renders when backend visibility is enabled', () => {
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="onboarding-steps"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="overview-step"]').exists()).toBe(true);
- });
-
- it('does not render when backend visibility is disabled', () => {
- onboardingModalStoreState.isVisible.value = false;
-
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
- });
-
- it('does not render when modal display is blocked', () => {
- onboardingStatusStore.canDisplayOnboardingModal.value = false;
-
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
});
- it('shows the activation step for ENOKEYFILE1', () => {
- activationCodeDataStore.registrationState.value = 'ENOKEYFILE1';
- onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE';
+ it('renders the current step from bootstrap wizard state', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'],
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
const wrapper = mountComponent();
+ await flushPromises();
- expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="onboarding-steps"]').text()).toContain(
+ 'OVERVIEW,CONFIGURE_SETTINGS,ADD_PLUGINS|1'
+ );
});
- it('hides the internal boot step when boot transfer is unavailable', () => {
- internalBootVisibilityResult.value = {
- bootedFromFlashWithInternalBootSetup: false,
- enableBootTransfer: 'no',
+ it('falls forward to the nearest visible step when the saved step is no longer visible', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_BOOT',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
};
- onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT';
const wrapper = mountComponent();
+ await flushPromises();
- expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true);
});
- it('keeps the internal boot step visible even when the server reports prior internal boot setup', () => {
- internalBootVisibilityResult.value = {
- bootedFromFlashWithInternalBootSetup: true,
- enableBootTransfer: 'yes',
+ it('persists step transitions with nested draft data before navigating', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
};
- onboardingDraftStore.currentStepId.value = 'CONFIGURE_BOOT';
-
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="internal-boot-step"]').exists()).toBe(true);
- });
-
- it('keeps a resumed activation step visible while activation state is still loading', async () => {
- activationCodeDataStore.loading.value = true;
- activationCodeDataStore.hasActivationCode.value = false;
- activationCodeDataStore.registrationState.value = null;
- onboardingDraftStore.currentStepId.value = 'ACTIVATE_LICENSE';
const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true);
- expect(onboardingDraftStore.setCurrentStep).not.toHaveBeenCalledWith('SUMMARY');
-
- activationCodeDataStore.loading.value = false;
- activationCodeDataStore.hasActivationCode.value = true;
- activationCodeDataStore.registrationState.value = 'ENOKEYFILE';
await flushPromises();
- expect(wrapper.find('[data-testid="license-step"]').exists()).toBe(true);
- expect(onboardingDraftStore.currentStepId.value).toBe('ACTIVATE_LICENSE');
- });
-
- it('opens exit confirmation when close button is clicked', async () => {
- const wrapper = mountComponent();
-
- await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
+ await wrapper.get('[data-testid="settings-step-complete"]').trigger('click');
+ await flushPromises();
- expect(wrapper.text()).toContain('Exit onboarding?');
- expect(wrapper.text()).toContain('Exit setup');
+ expect(saveOnboardingDraftMock).toHaveBeenCalledTimes(1);
+ expect(saveOnboardingDraftMock).toHaveBeenCalledWith({
+ input: {
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ useSsh: true,
+ },
+ plugins: undefined,
+ internalBoot: undefined,
+ },
+ navigation: {
+ currentStepId: 'ADD_PLUGINS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ },
+ });
+ expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true);
});
- it('shows the internal boot restart guidance when exiting after internal boot was applied', async () => {
- onboardingDraftStore.internalBootApplySucceeded.value = true;
-
- const wrapper = mountComponent();
-
- await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
-
- expect(wrapper.text()).toContain('Internal boot has been configured.');
- expect(wrapper.text()).toContain('Please restart manually when convenient');
- });
+ it('blocks navigation and offers a close path when a save fails', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+ saveOnboardingDraftMock.mockRejectedValueOnce(new Error('offline'));
- it('closes onboarding through the backend-owned close path', async () => {
const wrapper = mountComponent();
-
- await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
await flushPromises();
- const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup'));
- expect(exitButton).toBeTruthy();
- await exitButton!.trigger('click');
+ await wrapper.get('[data-testid="settings-step-complete"]').trigger('click');
await flushPromises();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1);
- expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
- });
-
- it('closes the modal from next steps after draft cleanup instead of persisting step 2 again', async () => {
- onboardingDraftStore.currentStepId.value = 'NEXT_STEPS';
- cleanupOnboardingStorageMock.mockImplementation(() => {
- onboardingDraftStore.currentStepId.value = null;
- });
+ expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true);
+ expect(wrapper.get('[data-testid="settings-step-error"]').text()).not.toBe('');
- const wrapper = mountComponent();
+ const closeButton = wrapper
+ .findAll('button')
+ .find((button) => button.text().trim().toLowerCase().includes('exit'));
+ expect(closeButton).toBeTruthy();
- await wrapper.find('[data-testid="next-step-complete"]').trigger('click');
+ await closeButton!.trigger('click');
await flushPromises();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1);
- expect(onboardingDraftStore.setCurrentStep).not.toHaveBeenCalledWith('CONFIGURE_SETTINGS');
- expect(onboardingDraftStore.currentStepId.value).toBeNull();
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith('SAVE_FAILURE');
});
- it('reloads the page when completing a manually opened wizard', async () => {
- onboardingModalStoreState.sessionSource.value = 'manual';
- onboardingDraftStore.currentStepId.value = 'NEXT_STEPS';
+ it('opens exit confirmation and closes through the backend-owned close path', async () => {
const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined);
-
const wrapper = mountComponent();
await flushPromises();
- await wrapper.get('[data-testid="next-step-complete"]').trigger('click');
- await flushPromises();
-
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1);
- expect(reloadSpy).toHaveBeenCalledTimes(1);
- });
-
- it('shows a loading state while exit confirmation is closing the modal', async () => {
- let closeModalDeferred:
- | {
- promise: Promise;
- resolve: (value: boolean) => void;
- }
- | undefined;
- onboardingModalStoreState.closeModal.mockImplementation(() => {
- let resolve!: (value: boolean) => void;
- const promise = new Promise((innerResolve) => {
- resolve = innerResolve;
- });
- closeModalDeferred = { promise, resolve };
- return promise;
- });
-
- const wrapper = mountComponent();
-
await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
await flushPromises();
- const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup'));
- expect(exitButton).toBeTruthy();
- await exitButton!.trigger('click');
- await flushPromises();
-
- expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
- expect(wrapper.text()).toContain('Closing setup...');
-
- if (closeModalDeferred) {
- closeModalDeferred.resolve(true);
- }
- await flushPromises();
- });
-
- it('closes onboarding without frontend completion logic', async () => {
- const wrapper = mountComponent();
-
- await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
- await flushPromises();
+ expect(wrapper.text()).toContain('Exit onboarding?');
- const exitButton = wrapper.findAll('button').find((button) => button.text().includes('Exit setup'));
+ const exitButton = findButtonByText(wrapper, 'Exit setup');
expect(exitButton).toBeTruthy();
await exitButton!.trigger('click');
await flushPromises();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledTimes(1);
- });
-
- it('does not reopen upgrade onboarding just from descriptive status when backend visibility is closed', () => {
- onboardingModalStoreState.isVisible.value = false;
- onboardingStatusStore.isVersionDrift.value = true;
- onboardingStatusStore.completedAtVersion.value = '7.2.4';
-
- const wrapper = mountComponent();
-
- expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith(undefined);
+ expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
});
- it('hides the X button when internal boot lockdown is active', () => {
- onboardingDraftStore.internalBootApplyAttempted.value = true;
+ it('hides exit controls and back navigation when internal boot is locked', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'SUMMARY'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ };
const wrapper = mountComponent();
+ await flushPromises();
expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false);
});
- it('passes showBack=false to step components when internal boot lockdown is active', async () => {
- onboardingDraftStore.internalBootApplyAttempted.value = true;
- onboardingDraftStore.currentStepId.value = 'CONFIGURE_SETTINGS';
+ it('carries summary-owned internal boot state into the next save', async () => {
+ wizardRef.value = {
+ currentStepId: 'SUMMARY',
+ visibleStepIds: ['OVERVIEW', 'SUMMARY', 'NEXT_STEPS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
const wrapper = mountComponent();
+ await flushPromises();
- expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false);
+ await wrapper.get('[data-testid="summary-step-mark-locked"]').trigger('click');
+ await wrapper.get('[data-testid="summary-step-complete"]').trigger('click');
+ await flushPromises();
+
+ expect(saveOnboardingDraftMock).toHaveBeenCalledWith({
+ input: {
+ draft: {
+ coreSettings: undefined,
+ plugins: undefined,
+ internalBoot: undefined,
+ },
+ navigation: {
+ currentStepId: 'NEXT_STEPS',
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ },
+ });
+ expect(wrapper.find('[data-testid="next-step"]').exists()).toBe(true);
});
- it('does not open exit confirmation when locked and X area is somehow triggered', async () => {
- onboardingDraftStore.internalBootApplyAttempted.value = true;
+ it('closes from the final step without saving again', async () => {
+ const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined);
+ wizardRef.value = {
+ currentStepId: 'NEXT_STEPS',
+ visibleStepIds: ['OVERVIEW', 'NEXT_STEPS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
const wrapper = mountComponent();
+ await flushPromises();
- expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false);
- expect(wrapper.text()).not.toContain('Exit onboarding?');
+ await wrapper.get('[data-testid="next-step-complete"]').trigger('click');
+ await flushPromises();
+
+ expect(saveOnboardingDraftMock).not.toHaveBeenCalled();
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith(undefined);
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
index 741898a891..98f74c188b 100644
--- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
@@ -61,10 +61,6 @@ vi.mock('@unraid/ui', () => ({
},
}));
-vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => reactive(draftStore),
-}));
-
vi.mock('~/components/Onboarding/store/activationCodeData', () => ({
useActivationCodeDataStore: () => reactive(activationCodeDataStore),
}));
@@ -114,6 +110,17 @@ describe('OnboardingNextStepsStep', () => {
const onComplete = vi.fn();
const wrapper = mount(OnboardingNextStepsStep, {
props: {
+ draft: {
+ internalBoot: {
+ bootMode: draftStore.internalBootSelection ? 'storage' : 'usb',
+ skipped: draftStore.internalBootSelection === null,
+ selection: draftStore.internalBootSelection,
+ },
+ },
+ internalBootState: {
+ applyAttempted: draftStore.internalBootApplyAttempted,
+ applySucceeded: draftStore.internalBootApplySucceeded,
+ },
onComplete,
showBack: true,
},
@@ -140,6 +147,18 @@ describe('OnboardingNextStepsStep', () => {
`,
},
+ InternalBootConfirmDialog: {
+ props: ['open', 'action', 'disabled'],
+ emits: ['confirm', 'cancel'],
+ template: `
+
+
{{ action === 'shutdown' ? 'Confirm Shutdown' : 'Confirm Reboot' }}
+
Please do NOT remove your Unraid flash drive
+
I Understand
+
Cancel
+
+ `,
+ },
},
},
});
diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
index 5a911c6115..f1f131b6db 100644
--- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
@@ -9,8 +9,6 @@ import { createTestI18n } from '../../utils/i18n';
const { draftStore, installedPluginsLoading, installedPluginsResult, useQueryMock } = vi.hoisted(() => ({
draftStore: {
selectedPlugins: new Set(),
- pluginSelectionInitialized: false,
- setPlugins: vi.fn(),
},
installedPluginsLoading: {
value: false,
@@ -36,10 +34,6 @@ vi.mock('@unraid/ui', () => ({
},
}));
-vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => draftStore,
-}));
-
vi.mock('@vue/apollo-composable', async () => {
const actual =
await vi.importActual('@vue/apollo-composable');
@@ -54,7 +48,6 @@ describe('OnboardingPluginsStep', () => {
vi.clearAllMocks();
document.body.innerHTML = '';
draftStore.selectedPlugins = new Set();
- draftStore.pluginSelectionInitialized = false;
installedPluginsLoading.value = false;
installedPluginsResult.value = {
installedUnraidPlugins: [],
@@ -76,6 +69,9 @@ describe('OnboardingPluginsStep', () => {
onComplete: vi.fn(),
onBack: vi.fn(),
onSkip: vi.fn(),
+ initialDraft: {
+ selectedIds: Array.from(draftStore.selectedPlugins),
+ },
showBack: true,
showSkip: true,
...overrides,
@@ -132,11 +128,10 @@ describe('OnboardingPluginsStep', () => {
expect(nextButton).toBeTruthy();
await nextButton!.trigger('click');
- expect(draftStore.setPlugins).toHaveBeenCalled();
- const lastCallIndex = draftStore.setPlugins.mock.calls.length - 1;
- const selected = draftStore.setPlugins.mock.calls[lastCallIndex][0] as Set;
- expect(Array.from(selected)).toEqual(['community-apps']);
expect(props.onComplete).toHaveBeenCalledTimes(1);
+ expect(props.onComplete).toHaveBeenCalledWith({
+ selectedIds: ['community-apps'],
+ });
});
it('persists already installed plugins alongside manual selections', async () => {
@@ -161,11 +156,10 @@ describe('OnboardingPluginsStep', () => {
expect(nextButton).toBeTruthy();
await nextButton!.trigger('click');
- expect(draftStore.setPlugins).toHaveBeenCalled();
- const lastCallIndex = draftStore.setPlugins.mock.calls.length - 1;
- const selected = draftStore.setPlugins.mock.calls[lastCallIndex][0] as Set;
- expect(Array.from(selected).sort()).toEqual(['community-apps', 'fix-common-problems', 'tailscale']);
expect(props.onComplete).toHaveBeenCalledTimes(1);
+ expect(props.onComplete).toHaveBeenCalledWith({
+ selectedIds: ['community-apps', 'fix-common-problems', 'tailscale'],
+ });
});
it('disables the primary action until installed plugins finish loading', async () => {
@@ -185,7 +179,6 @@ describe('OnboardingPluginsStep', () => {
});
it('skip clears selection and calls onSkip', async () => {
- draftStore.pluginSelectionInitialized = true;
draftStore.selectedPlugins = new Set(['community-apps']);
const { wrapper, props } = mountComponent();
@@ -199,15 +192,14 @@ describe('OnboardingPluginsStep', () => {
expect(skipButton).toBeTruthy();
await skipButton!.trigger('click');
- expect(draftStore.setPlugins).toHaveBeenCalledTimes(1);
- const selected = draftStore.setPlugins.mock.calls[0][0] as Set;
- expect(selected.size).toBe(0);
expect(props.onSkip).toHaveBeenCalledTimes(1);
+ expect(props.onSkip).toHaveBeenCalledWith({
+ selectedIds: [],
+ });
expect(props.onComplete).not.toHaveBeenCalled();
});
it('skip preserves detected installed plugins without keeping manual selections', async () => {
- draftStore.pluginSelectionInitialized = true;
draftStore.selectedPlugins = new Set(['community-apps', 'tailscale']);
installedPluginsResult.value = {
installedUnraidPlugins: ['fix.common.problems.plg'],
@@ -224,10 +216,10 @@ describe('OnboardingPluginsStep', () => {
expect(skipButton).toBeTruthy();
await skipButton!.trigger('click');
- expect(draftStore.setPlugins).toHaveBeenCalledTimes(1);
- const selected = draftStore.setPlugins.mock.calls[0][0] as Set;
- expect(Array.from(selected)).toEqual(['fix-common-problems']);
expect(props.onSkip).toHaveBeenCalledTimes(1);
+ expect(props.onSkip).toHaveBeenCalledWith({
+ selectedIds: ['fix-common-problems'],
+ });
expect(props.onComplete).not.toHaveBeenCalled();
});
});
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 32eb5cdf83..09b6186bdf 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -141,14 +141,6 @@ vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({
},
}));
-vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
- useOnboardingDraftStore: () => ({
- ...draftStore,
- setInternalBootApplySucceeded: setInternalBootApplySucceededMock,
- setInternalBootApplyAttempted: setInternalBootApplyAttemptedMock,
- }),
-}));
-
vi.mock('~/components/Onboarding/store/activationCodeData', () => ({
useActivationCodeDataStore: () => ({
registrationState: registrationStateRef,
@@ -241,6 +233,32 @@ const mountComponent = (props: Record = {}) => {
const onComplete = vi.fn();
const wrapper = mount(OnboardingSummaryStep, {
props: {
+ draft: {
+ coreSettings: {
+ serverName: draftStore.serverName,
+ serverDescription: draftStore.serverDescription,
+ timeZone: draftStore.selectedTimeZone,
+ theme: draftStore.selectedTheme,
+ language: draftStore.selectedLanguage,
+ useSsh: draftStore.useSsh,
+ },
+ plugins: {
+ selectedIds: Array.from(draftStore.selectedPlugins),
+ },
+ internalBoot: {
+ bootMode: draftStore.bootMode,
+ skipped: draftStore.internalBootSkipped,
+ selection: draftStore.internalBootSelection,
+ },
+ },
+ internalBootState: {
+ applyAttempted: draftStore.internalBootApplyAttempted,
+ applySucceeded: draftStore.internalBootApplySucceeded,
+ },
+ onInternalBootStateChange: vi.fn((state: { applyAttempted: boolean; applySucceeded: boolean }) => {
+ setInternalBootApplyAttemptedMock(state.applyAttempted);
+ setInternalBootApplySucceededMock(state.applySucceeded);
+ }),
onComplete,
showBack: true,
...props,
diff --git a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts
index 9c564ad302..c40e7de3ec 100644
--- a/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts
+++ b/web/__test__/components/Onboarding/onboardingStorageCleanup.test.ts
@@ -1,9 +1,6 @@
-import { createPinia, setActivePinia } from 'pinia';
-
import { beforeEach, describe, expect, it } from 'vitest';
import { ONBOARDING_MODAL_HIDDEN_STORAGE_KEY } from '~/components/Onboarding/constants';
-import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft';
import {
cleanupOnboardingStorage,
clearLegacyOnboardingModalHiddenSessionState,
@@ -12,7 +9,6 @@ import {
describe('onboardingStorageCleanup', () => {
beforeEach(() => {
- setActivePinia(createPinia());
window.localStorage.clear();
window.sessionStorage.clear();
});
@@ -29,23 +25,14 @@ describe('onboardingStorageCleanup', () => {
expect(window.localStorage.getItem('unrelatedKey')).toBe('keep');
});
- it('resets the live onboarding draft store when clearing storage', () => {
- const draftStore = useOnboardingDraftStore();
- draftStore.setCoreSettings({
- serverName: 'tower',
- serverDescription: 'test',
- timeZone: 'UTC',
- theme: 'black',
- language: 'en_US',
- useSsh: true,
- });
- draftStore.setCurrentStep('CONFIGURE_BOOT');
+ it('removes every onboarding draft storage variant without touching session state', () => {
+ window.localStorage.setItem('pinia-onboardingDraft:legacy', '{"currentStepId":"SUMMARY"}');
+ window.sessionStorage.setItem(ONBOARDING_MODAL_HIDDEN_STORAGE_KEY, 'true');
clearOnboardingDraftStorage();
- expect(draftStore.hasResumableDraft).toBe(false);
- expect(draftStore.currentStepId).toBeNull();
- expect(draftStore.coreSettingsInitialized).toBe(false);
+ expect(window.localStorage.getItem('pinia-onboardingDraft:legacy')).toBeNull();
+ expect(window.sessionStorage.getItem(ONBOARDING_MODAL_HIDDEN_STORAGE_KEY)).toBe('true');
});
it('clears legacy hidden onboarding key from sessionStorage', () => {
diff --git a/web/__test__/store/onboardingContextData.test.ts b/web/__test__/store/onboardingContextData.test.ts
index c9b606e6e6..297445c1ac 100644
--- a/web/__test__/store/onboardingContextData.test.ts
+++ b/web/__test__/store/onboardingContextData.test.ts
@@ -54,12 +54,21 @@ describe('OnboardingContextData Store', () => {
onboarding: {
status: 'INCOMPLETE',
onboardingState: { isFreshInstall: true },
+ wizard: {
+ currentStepId: 'OVERVIEW',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS'],
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ },
},
},
- vars: {
- bootedFromFlashWithInternalBootSetup: false,
- enableBootTransfer: 'yes',
- },
},
false
)
@@ -75,9 +84,9 @@ describe('OnboardingContextData Store', () => {
expect(store.activationCode).toEqual({ code: 'ABC-123' });
expect(store.onboarding).toMatchObject({ status: 'INCOMPLETE' });
expect(store.onboardingState).toMatchObject({ isFreshInstall: true });
- expect(store.internalBootVisibility).toEqual({
- bootedFromFlashWithInternalBootSetup: false,
- enableBootTransfer: 'yes',
+ expect(store.wizard).toMatchObject({
+ currentStepId: 'OVERVIEW',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS'],
});
expect(store.loading).toBe(false);
});
@@ -93,6 +102,6 @@ describe('OnboardingContextData Store', () => {
expect(store.error).toBe(queryError);
expect(store.onboarding).toBeNull();
expect(store.activationCode).toBeNull();
- expect(store.internalBootVisibility).toBeNull();
+ expect(store.wizard).toBeNull();
});
});
From c21f31e33aa7c499297745cc839eb79d539fbab1 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 17:28:19 -0400
Subject: [PATCH 04/52] docs(onboarding): replace outdated wizard docs
- Purpose: make the repository documentation match the new server-owned onboarding implementation.
- Before: the docs described a client-owned draft store, hardcoded step flow, and an old internal-boot port-differences document that no longer matched the code.
- Problem: anyone reading the old docs would implement against the wrong architecture and misunderstand how bootstrap, persistence, and legacy cleanup now work.
- Now: the docs describe the server-owned bootstrap contract, tracker-backed wizard state, save-on-transition model, visible-step computation, internal-boot behavior, and exact legacy-key cleanup behavior.
Files changed in this commit:
- web/src/components/Onboarding/ONBOARDING_WIZARD.md: added the new source-of-truth onboarding architecture guide covering bootstrap fields, tracker shape, save rules, step visibility, current-step fallback, failure handling, and legacy cleanup semantics.
- web/src/components/Onboarding/UPGRADE_ONBOARDING.md: removed the obsolete upgrade-era onboarding doc that described the deleted client-draft flow.
- docs/onboarding-internal-boot.md: added focused documentation for how the internal-boot step now fits into the server-owned wizard model.
- docs/onboarding-internal-boot-port-differences.md: removed the old port-differences note because it was documenting a previous transitional implementation rather than the current shipped architecture.
---
...boarding-internal-boot-port-differences.md | 25 --
docs/onboarding-internal-boot.md | 115 +++++
.../Onboarding/ONBOARDING_WIZARD.md | 200 +++++++++
.../Onboarding/UPGRADE_ONBOARDING.md | 401 ------------------
4 files changed, 315 insertions(+), 426 deletions(-)
delete mode 100644 docs/onboarding-internal-boot-port-differences.md
create mode 100644 docs/onboarding-internal-boot.md
create mode 100644 web/src/components/Onboarding/ONBOARDING_WIZARD.md
delete mode 100644 web/src/components/Onboarding/UPGRADE_ONBOARDING.md
diff --git a/docs/onboarding-internal-boot-port-differences.md b/docs/onboarding-internal-boot-port-differences.md
deleted file mode 100644
index 4aa778d171..0000000000
--- a/docs/onboarding-internal-boot-port-differences.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Onboarding Internal Boot Port: Non-1:1 Differences
-
-This list tracks behavior that is intentionally or currently different from the webgui implementation in commit `edff0e5202c0efeaa613efb8dfc599453d0fe5cb`.
-
-## Current Differences
-
-- Data source for setup context:
- - Webgui: `Main/PoolDevices`/websocket-rendered context + `devs.ini`-based behavior.
- - Onboarding: existing GraphQL (`array`, `vars`, `shares`, `disks`).
-
-- Device option labeling:
- - Webgui formats labels via PHP helpers.
- - Onboarding formats labels in Vue (` - ()`).
-
-- Dialog auto-open via URL flag:
- - Webgui supports `?createbootpool`.
- - Onboarding step does not support URL-triggered auto-open.
-
-- Reboot flow:
- - Webgui dialog path is "Activate and Reboot" in one flow.
- - Onboarding applies `mkbootpool` without reboot in summary, then exposes reboot as a separate next-step action.
-
-- Internal-boot visibility source:
- - Onboarding hides the step using `vars.enableBootTransfer` (`no` means already internal boot).
- - This matches `var.ini` semantics, but is still API-driven rather than websocket-rendered page context.
diff --git a/docs/onboarding-internal-boot.md b/docs/onboarding-internal-boot.md
new file mode 100644
index 0000000000..dde8682ad3
--- /dev/null
+++ b/docs/onboarding-internal-boot.md
@@ -0,0 +1,115 @@
+# Onboarding Internal Boot
+
+## Overview
+
+The internal-boot step is part of the main onboarding wizard, but it still has its own operational state because it can perform real disk actions.
+
+The step uses two layers of state:
+
+- `draft.internalBoot`
+ - user intent that should survive resume
+- `internalBootState`
+ - operational result flags used by Summary and Next Steps
+
+Both are stored in `onboarding-tracker.json`, but only the draft is user-entered data.
+
+## Visibility
+
+Internal boot is still server-gated.
+
+`api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts` shows `CONFIGURE_BOOT` only when:
+
+- `getters.emhttp().var.enableBootTransfer === 'yes'`
+
+That means the step is recomputed on every bootstrap/read instead of being frozen in the tracker.
+
+## Saved Shape
+
+The persisted internal-boot draft lives under:
+
+```json
+{
+ "draft": {
+ "internalBoot": {
+ "bootMode": "storage",
+ "skipped": false,
+ "selection": {
+ "poolName": "cache",
+ "slotCount": 2,
+ "devices": ["disk1", "disk2"],
+ "bootSizeMiB": 32768,
+ "updateBios": true,
+ "poolMode": "hybrid"
+ }
+ }
+ },
+ "internalBootState": {
+ "applyAttempted": true,
+ "applySucceeded": false
+ }
+}
+```
+
+`draft.internalBoot` means:
+
+- `bootMode`
+ - `usb` or `storage`
+- `skipped`
+ - whether the user explicitly skipped the step
+- `selection`
+ - the storage pool choice when boot moves off USB
+
+`internalBootState` means:
+
+- `applyAttempted`
+- `applySucceeded`
+
+Those flags are separate so the draft stays about user intent, not execution bookkeeping.
+
+## Frontend Flow
+
+Main wizard usage:
+
+- `web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue`
+- `web/src/components/Onboarding/steps/OnboardingSummaryStep.vue`
+- `web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue`
+
+Standalone/admin flow:
+
+- `web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue`
+
+The step component now takes `initialDraft` and emits committed snapshots back to the modal instead of mutating a shared Pinia draft store.
+
+## Persistence Rules
+
+Internal-boot choices are saved only when the user leaves the step or commits a downstream step transition.
+
+That includes:
+
+- continuing from `CONFIGURE_BOOT`
+- skipping `CONFIGURE_BOOT`
+- navigating back from later steps after internal-boot changes
+
+Summary updates `internalBootState` after apply attempts so Next Steps can render the correct follow-up actions.
+
+## Current Behavior Notes
+
+- The step is hidden as soon as the server no longer qualifies for internal boot transfer on the next bootstrap/read.
+- The wizard intentionally keeps the current 1:1 behavior where externally changed system state can remove the step after a reopen.
+- A future session-only sticky-step behavior can be added separately without changing the durable tracker model.
+
+## Testing
+
+Automated coverage:
+
+- `web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts`
+- `web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts`
+- `api/src/unraid-api/config/onboarding-tracker.service.spec.ts`
+- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts`
+
+Recommended manual checks:
+
+1. Start on a server with `enableBootTransfer=yes` and confirm `CONFIGURE_BOOT` appears.
+2. Choose storage-backed boot, continue, refresh, and confirm the step resumes from server state.
+3. Apply internal boot from Summary and confirm `internalBootState` drives the post-apply UI.
+4. Reopen onboarding on a server that no longer qualifies and confirm the step is omitted from `visibleStepIds`.
diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
new file mode 100644
index 0000000000..6af5dff616
--- /dev/null
+++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
@@ -0,0 +1,200 @@
+# Onboarding Wizard
+
+## Overview
+
+The onboarding wizard is now server-owned.
+
+- Durable progress lives in `onboarding-tracker.json`.
+- The API decides which step IDs are visible for the current server.
+- The web app keeps only in-memory draft state while the user is editing a step.
+- Nothing in onboarding uses `localStorage` for draft persistence anymore.
+
+## Data Flow
+
+### Bootstrap
+
+`web/src/components/Onboarding/store/onboardingContextData.ts` loads `ONBOARDING_BOOTSTRAP_QUERY`.
+
+That query returns:
+
+- `customization.onboarding.status`
+- `customization.onboarding.shouldOpen`
+- `customization.onboarding.onboardingState`
+- `customization.onboarding.wizard`
+
+How those fields are determined:
+
+- `status`
+ - computed server-side from tracker completion state plus version direction
+ - `INCOMPLETE` when `completed=false`
+ - `UPGRADE` / `DOWNGRADE` when onboarding was completed on a different supported version
+ - `COMPLETED` when onboarding was already completed for the current effective version line
+- `shouldOpen`
+ - computed server-side from `forceOpen`, current version support, completion state, and the in-memory bypass flag
+ - current formula is effectively `!isBypassed && (forceOpen || (isVersionSupported && !completed))`
+- `onboardingState`
+ - derived from current server facts, not the tracker file
+ - includes registration state, whether the server is already registered, whether this is a fresh install, whether an activation code exists, and whether activation is still required
+- `wizard`
+ - built from durable tracker state plus live server state
+ - `visibleStepIds` are computed on read
+ - `currentStepId` comes from persisted navigation state, then falls to the nearest visible step if the saved step is no longer valid
+ - `draft` and `internalBootState` come from `onboarding-tracker.json`
+
+`wizard` contains:
+
+- `currentStepId`
+- `visibleStepIds`
+- `draft`
+- `internalBootState`
+
+### Server State
+
+`api/src/unraid-api/config/onboarding-tracker.json` stores the durable wizard state:
+
+```json
+{
+ "completed": false,
+ "completedAtVersion": null,
+ "forceOpen": false,
+ "draft": {
+ "coreSettings": {},
+ "plugins": {},
+ "internalBoot": {}
+ },
+ "navigation": {
+ "currentStepId": "CONFIGURE_SETTINGS"
+ },
+ "internalBootState": {
+ "applyAttempted": false,
+ "applySucceeded": false
+ }
+}
+```
+
+The tracker shape is defined in `api/src/unraid-api/config/onboarding-tracker.model.ts`.
+
+### Client State
+
+`web/src/components/Onboarding/OnboardingModal.vue` owns the transient in-memory wizard state.
+
+- It hydrates `localDraft`, `localCurrentStepId`, and `localInternalBootState` from the bootstrap query.
+- Step components receive draft slices through props.
+- Step components return committed values through callbacks like `onComplete`, `onBack`, and `onSkip`.
+
+The old Pinia draft store was removed:
+
+- `web/src/components/Onboarding/store/onboardingDraft.ts`
+
+## Saving Rules
+
+The wizard saves only on step transitions.
+
+Current transitions that persist:
+
+- `Continue`
+- `Back`
+- `Skip`
+
+Typing inside a step stays local until one of those actions happens.
+
+`Back` persists the draft snapshot from the step the user is leaving, then stores the previous visible step as `navigation.currentStepId`. It is not a no-op navigation button.
+
+Persistence goes through:
+
+- `web/src/components/Onboarding/graphql/saveOnboardingDraft.mutation.ts`
+- `api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts`
+- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts`
+
+The API mutation is intentionally minimal and returns `Boolean`.
+
+## Visible Steps
+
+The frontend still owns presentation:
+
+- step component registry
+- step titles/descriptions/icons
+- per-step UI implementation
+
+The API owns visibility:
+
+- `OnboardingService.getVisibleWizardStepIds()`
+
+Current server-driven rules:
+
+- `CONFIGURE_BOOT` appears only when internal boot transfer is available.
+- `ACTIVATE_LICENSE` appears only when activation is still required.
+
+The visible step list is computed on read and is not persisted in the tracker.
+
+If the saved `currentStepId` is no longer visible, the API falls to the nearest valid visible step by using a fixed step order, not by doing math on a numeric index.
+
+Example:
+
+- saved `currentStepId = CONFIGURE_BOOT`
+- current visible steps = `OVERVIEW`, `CONFIGURE_SETTINGS`, `ADD_PLUGINS`, `SUMMARY`, `NEXT_STEPS`
+- `CONFIGURE_BOOT` is no longer visible
+- the API walks forward in the canonical order first and returns `ADD_PLUGINS`
+
+If there is no later visible step, it walks backward. If neither search finds a match, it falls to the first visible step.
+
+## Failure Handling
+
+Step-transition saves are blocking.
+
+- Navigation does not advance until `saveOnboardingDraft` succeeds.
+- The active step shows a loading state while the save is in flight.
+- A failed save surfaces a retryable error in the modal.
+- The user can exit with `Close onboarding`.
+
+`Close onboarding` currently reuses the existing close flow and logs the close reason through GraphQL when it came from a save failure.
+
+## Legacy Cleanup
+
+The only backward-compatibility behavior left is cleanup of old browser draft keys.
+
+`web/src/components/Onboarding/store/onboardingStorageCleanup.ts` removes:
+
+- `onboardingDraft`
+- any related legacy key containing `onboardingDraft`
+- the old session-hidden modal flag
+
+This runs best-effort and exists only to delete stale browser state. The cleanup does not migrate old values into the new server draft, and the new flow never reads onboarding progress from browser storage again.
+
+Concretely, cleanup works by calling `window.localStorage.removeItem('onboardingDraft')`, then scanning every localStorage key and removing any key whose name contains `onboardingDraft` (for example old persisted Pinia keys). It also removes the old session-hidden modal key from `sessionStorage`.
+
+## Adding Or Changing Steps
+
+### Add a New Durable Draft Section
+
+1. Update the tracker model in `api/src/unraid-api/config/onboarding-tracker.model.ts`.
+2. Update GraphQL inputs/types in:
+ - `api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts`
+ - `api/src/unraid-api/graph/resolvers/customization/activation-code.model.ts`
+3. Normalize and persist the new fields in `OnboardingTrackerService`.
+4. Map GraphQL input to tracker shape in `OnboardingService.saveOnboardingDraft()`.
+
+### Add a New Step
+
+1. Create or update the Vue step component under `web/src/components/Onboarding/steps`.
+2. Register presentation metadata in `web/src/components/Onboarding/stepRegistry.ts`.
+3. Add the step ID to the shared step types/enums on both API and web.
+4. Update the server-side visibility rules in `OnboardingService`.
+5. Wire the step into `OnboardingModal.vue`.
+
+## Testing
+
+Primary coverage lives in:
+
+- `api/src/unraid-api/config/onboarding-tracker.service.spec.ts`
+- `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts`
+- `api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts`
+- `web/__test__/components/Onboarding`
+- `web/__test__/store/onboardingContextData.test.ts`
+
+Recommended manual checks:
+
+1. Open onboarding on an incomplete server and confirm the modal hydrates from the server.
+2. Move between steps and refresh after a transition to confirm resume uses the saved server step.
+3. Force a save failure and confirm navigation blocks, the error is visible, and `Close onboarding` exits.
+4. Verify that no `onboardingDraft` key is recreated in `localStorage`.
diff --git a/web/src/components/Onboarding/UPGRADE_ONBOARDING.md b/web/src/components/Onboarding/UPGRADE_ONBOARDING.md
deleted file mode 100644
index cd3d635aac..0000000000
--- a/web/src/components/Onboarding/UPGRADE_ONBOARDING.md
+++ /dev/null
@@ -1,401 +0,0 @@
-# Upgrade Onboarding System
-
-## Overview
-
-This system uses a shared onboarding modal flow. Automatic modal display applies to any incomplete onboarding state on supported versions (`7.3.0+`), while completed patch-only releases stay quiet. Downgrade status is still tracked and can affect copy/behavior when the modal is opened manually. The API tracks onboarding completion state (`completed`, `completedAtVersion`) and computes the `shouldOpen` recommendation, while the web client applies final UI guards before showing the modal and decides which local steps to render.
-
-## How It Works
-
-### Backend (API)
-
-1. **Onboarding Tracker** - `api/src/unraid-api/config/onboarding-tracker.service.ts`
- - Reads current OS version from `/etc/unraid-version` (or `PATHS_UNRAID_DATA/unraid-version`)
- - Persists onboarding state to `PATHS_CONFIG_MODULES/onboarding-tracker.json`
- - Stores:
- - `completed` (boolean)
- - `completedAtVersion` (string | undefined)
-
-2. **GraphQL API** - `api/src/unraid-api/graph/resolvers/customization/`
- - Exposes `customization { onboarding { ... } }`
- - `onboarding` includes:
- - `status` (`INCOMPLETE`, `UPGRADE`, `DOWNGRADE`, `COMPLETED`)
- - `isPartnerBuild`, `completed`, `completedAtVersion`
- - `activationCode`
- - `onboardingState` (`registrationState`, `isRegistered`, `isFreshInstall`, `hasActivationCode`, `activationRequired`)
- - Status is computed server-side from tracker state + semver direction.
-
-### Frontend (Web)
-
-1. **Onboarding Store** - `web/src/components/Onboarding/store/onboardingStatus.ts`
- - Queries `ONBOARDING_QUERY` (`customization.onboarding`)
- - Enforces visibility guards:
- - minimum supported version (`7.3.0+`)
- - authenticated access (no unauthenticated GraphQL errors)
- - Exposes `shouldShowOnboarding` when status is `INCOMPLETE`
- - Auto-opens for incomplete onboarding on supported versions
-
-2. **Unified Modal** - `web/src/components/Onboarding/OnboardingModal.vue`
- - Automatically shows for incomplete onboarding on supported versions
- - Can be force-opened for any user via URL action/event triggers
- - Uses a client-side hardcoded step list (`HARDCODED_STEPS`)
- - Conditionally includes/removes `ACTIVATE_LICENSE` based on activation state
- - Resolves components from `stepRegistry`
- - Does not use server-provided per-step metadata.
-
-## Adding New Steps
-
-### 1. Create the Step Component
-
-Create a new step under `web/src/components/Onboarding/steps/`, following the same prop contract used by existing steps.
-
-Example:
-```vue
-
-```
-
-### 2. Register The Component And Step Metadata
-
-- Add the component mapping in `web/src/components/Onboarding/stepRegistry.ts` (`stepComponents`)
-- Add stepper metadata in `stepRegistry.ts` (`stepMetadata`) so the timeline has title/description/icon.
-
-### 3. Add Step ID To The Modal And Stepper Types
-
-- Update `StepId` unions in:
- - `web/src/components/Onboarding/OnboardingModal.vue`
- - `web/src/components/Onboarding/OnboardingSteps.vue`
-
-### 4. Add The Step To The Client-Side Flow
-
-- Update `HARDCODED_STEPS` in `web/src/components/Onboarding/OnboardingModal.vue`.
-- Choose whether it is required via the step's `required` flag.
-- If conditional, update the `availableSteps` / `filteredSteps` computed logic.
-
-### 5. Wire Per-Step Props In `currentStepProps`
-
-Add a `switch` case in `OnboardingModal.vue` so the step receives the right callbacks/flags (`onComplete`, `onBack`, `onSkip`, `showSkip`, etc.).
-
-### 6. Add/Update Tests
-
-At minimum, update:
-- `web/__test__/components/Onboarding/OnboardingModal.test.ts`
-- Any step-specific test file if the new step introduces custom behavior.
-
-## Integration
-
-`OnboardingModal.vue` is already integrated and handles both onboarding modes. No additional app wiring is usually required once the step is added to `stepRegistry` + `HARDCODED_STEPS`.
-
-## Testing
-
-To test upgrade/downgrade visibility:
-
-1. Edit `PATHS_CONFIG_MODULES/onboarding-tracker.json` (default: `/boot/config/plugins/dynamix.my.servers/configs/onboarding-tracker.json`)
-2. For upgrade flow, set:
- - `completed: true`
- - `completedAtVersion` to an older version than current OS version
-3. For incomplete flow, set:
- - `completed: false`
- - `completedAtVersion: null` (or omit)
-4. Restart the API
-5. Reload web UI and verify `customization.onboarding.status` and modal visibility
-
-For rapid local testing, the onboarding admin panel (`OnboardingAdminPanel.standalone.vue`) can also apply temporary GraphQL override state.
-
-To test activation-step gating:
-
-1. Ensure activation code exists (or remove it to test conditional logic)
-2. Verify `registrationState` is one of `ENOKEYFILE`, `ENOKEYFILE1`, `ENOKEYFILE2` for the license step to appear
-3. Restart the API
-4. Reload web UI and verify whether `ACTIVATE_LICENSE` is present in the step list
-
-## Notes
-
-- Automatic onboarding visibility is driven by backend `shouldOpen`, which now opens incomplete onboarding on supported versions
-- Patch-only releases still remain silent because upgrade detection ignores same-minor version drift
-- The modal automatically switches between fresh-install and version-drift copy
-- There is no server-side per-step completion tracking in this flow
-- Exiting onboarding can call `completeOnboarding`; temporary bypass does not
-- Summary applies and validates draft settings only; final completion happens from Next Steps
-- Version comparison uses semver for reliable ordering
-- The same modal component handles all modes for consistency
-- During apply in the summary step, if baseline core-settings query data is unavailable, onboarding runs in best-effort mode using trusted defaults plus draft values and still proceeds. This behavior is intentional to avoid hard-blocking onboarding when baseline reads are unavailable.
-- Core-settings timezone precedence is mode-aware: for initial setup (`onboarding.completed=false`), the step prefers non-empty draft timezone, then browser timezone, then API baseline; for completed onboarding (upgrade/downgrade paths), API baseline timezone remains authoritative.
-- Temporary onboarding bypass is available for support/partner workflows without marking onboarding complete (`Ctrl/Cmd + Alt + Shift + O`, `?onboarding=bypass`, `?onboarding=resume`). It is session-scoped and boot-aware.
-- Bypass persistence intentionally uses `sessionStorage + boot marker` (not `localStorage`) so it can survive in-session navigation but still expire after reboot.
-
-## Detailed Init Flow And Regression Matrix
-
-### Purpose
-
-This section is the reference for:
-
-1. What happens during onboarding initialization (API startup and summary-step readiness).
-2. Which regression cases are intentionally locked in tests.
-3. How to map behaviors to exact test files.
-
-### Source Of Truth Files
-
-- Web summary behavior: `web/src/components/Onboarding/steps/OnboardingSummaryStep.vue`
-- Web core settings behavior: `web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue`
-- Web installer behavior: `web/src/components/Onboarding/composables/usePluginInstaller.ts`
-- API startup/init behavior: `api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts`
-- API activation file helpers: `api/src/unraid-api/graph/resolvers/customization/activation-steps.util.ts`
-
-### API Init Flow (`OnboardingService.onModuleInit`)
-
-#### High-Level Sequence
-
-1. Read paths from store (`activationBase`, user dynamix config, ident config).
-2. Resolve activation directory (`/activation` vs legacy `/activate` fallback).
-3. Check first-boot completion via tracker.
-4. Load and validate `.activationcode` (JSON -> DTO -> class-validator).
-5. Apply customizations in order:
- - Partner banner
- - Display settings
- - Case model
-
-Notes:
-- Init no longer applies server identity by default. Server identity is applied in onboarding summary confirmation.
-- `onModuleInit()` does not call external internet APIs directly. It performs local disk operations and best-effort customization materialization.
-
-#### Call Matrix During Init
-
-| Step | Call Type | Target | Why |
-| --- | --- | --- | --- |
-| Resolve activation dir | filesystem access | candidate dirs from `getActivationDirCandidates()` | Detect active activation directory. |
-| Locate activation file | filesystem read dir | `*.activationcode` under activation dir | Find partner activation payload. |
-| Parse and validate | JSON parse + DTO validation | `ActivationCode` model | Enforce sanitized/validated inputs before mutation. |
-| Banner customization | filesystem copy/symlink | activation banner -> webgui banner path | Apply partner banner if present. |
-| Display customization | cfg file write | dynamix display section | Apply branding colors/banner flags. |
-| Case model customization | filesystem write/symlink | case-model asset + cfg | Set partner case model when asset exists. |
-
-#### API Init Decision Table
-
-| Case | Activation dir available | Tracker completed | Activation code valid | Expected behavior |
-| --- | --- | --- | --- | --- |
-| I1 | No | N/A | N/A | Skip setup. |
-| I2 | Yes | Yes | Any | Skip customization as already completed. |
-| I3 | Yes | No | No/invalid | No activation customizations applied. |
-| I4 | Yes | No | Yes | Apply banner/display/case-model in sequence. |
-| I5 | Yes | First init: No, second init: Yes | Yes | First init applies; subsequent init is idempotent skip. |
-
-### Web Summary-Step Init And Apply Flow
-
-#### Readiness/Initialization
-
-On mount, summary step starts:
-
-1. `GetCoreSettings` query (baseline).
-2. `InstalledUnraidPlugins` query.
-3. `GetAvailableLanguages` query.
-4. A 10s readiness timer; if baseline is still unavailable, apply can continue in best-effort mode.
-
-#### Apply Sequence (When User Clicks Confirm)
-
-1. Compute baseline vs target settings.
-2. Apply core settings (timezone, identity, theme).
-3. Apply locale (with language install when needed).
-4. Install selected plugins not already installed.
-5. Apply SSH settings (optimistic verification).
-6. Show final result dialog based on apply/result precedence.
-7. Advance to Next Steps after user confirmation.
-
-#### Endpoint/Operation Mapping
-
-| Purpose | Operation |
-| --- | --- |
-| Baseline settings | `GetCoreSettings` |
-| Installed plugin names | `InstalledUnraidPlugins` |
-| Language metadata | `GetAvailableLanguages` |
-| Update timezone | `UpdateSystemTime` |
-| Update identity | `UpdateServerIdentity` |
-| Update theme | `setTheme` |
-| Update locale | `SetLocale` |
-| Update SSH | `UpdateSshSettings` |
-| Install plugin | `InstallPlugin` (+ operation tracking query/subscription) |
-| Install language pack | `InstallLanguage` (+ operation tracking query/subscription) |
-| Final onboarding completion | `CompleteOnboarding` (triggered from Next Steps) |
-
-### Temporary Bypass Controls
-
-Store: `web/src/components/Onboarding/store/onboardingModalVisibility.ts`
-Modal gate: `web/src/components/Onboarding/OnboardingModal.vue`
-
-Bypass is intentionally separate from onboarding completion state:
-
-1. Temporary bypass does not call `CompleteOnboarding`.
-2. Normal exit behavior still follows existing completion flow.
-3. Bypass suppresses automatic onboarding display; force-open remains user-initiated.
-
-Supported controls:
-
-- Keyboard shortcut: `Ctrl/Cmd + Alt + Shift + O`
-- URL query param: `?onboarding=bypass`
-- URL query param to resume: `?onboarding=resume`
-- URL query param to force open: `?onboarding=open`
-- Window event to force open: `window.dispatchEvent(new Event('unraid:onboarding:open'))`
-
-Persistence model:
-
-- Stored per browser session (`sessionStorage`) under `onboardingTemporaryBypass`.
-- Boot-aware: bypass stores a boot marker derived from server uptime, and is treated as invalid after reboot.
-- URL param is consumed once and removed from the URL via `history.replaceState`.
-
-#### Why This Persistence Model
-
-Bypass persistence intentionally uses `sessionStorage + boot marker` rather than `sessionStorage` alone or `localStorage`.
-
-`sessionStorage + boot marker` (chosen):
-
-- Meets operator expectation for partner testing: bypass can survive refresh/navigation in the same browser session.
-- Resets after server reboot even if browser tab/session remains open.
-- Keeps scope per browser session and avoids cross-session leakage.
-
-`sessionStorage` only (not chosen):
-
-- Survives refresh/navigation, but does not reset on reboot if the tab remains open.
-- Can leave onboarding unintentionally bypassed after reboot, which conflicts with expected re-check behavior.
-
-`localStorage` only (not chosen):
-
-- Persists across browser restarts and future sessions.
-- Makes temporary bypass too sticky and increases risk of users forgetting onboarding is still incomplete.
-- Harder to reason about during support because bypass state can linger indefinitely on a client.
-
-Net result:
-
-- The chosen model gives temporary convenience for active setup workflows while retaining reboot-bound safety and predictable re-entry into onboarding.
-
-### Core Settings Timezone Precedence
-
-Component: `web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue`
-
-The timezone shown/seeded in the core settings step depends on onboarding tracker completion:
-
-1. If `coreSettingsInitialized === true`, preserve draft timezone exactly (including intentional empty).
-2. If onboarding status is still loading, defer timezone override until status is known.
-3. If onboarding is initial setup (`onboarding.completed === false`):
- - Use non-empty draft timezone first.
- - Else use browser timezone (`Intl.DateTimeFormat().resolvedOptions().timeZone`) if available.
- - Else fallback to API baseline timezone.
-4. If onboarding is already completed (`onboarding.completed === true`), use API baseline timezone.
-5. If nothing is available, fallback chain remains trusted defaults.
-
-### Regression Test Matrices
-
-#### A) Summary Step: Core Setting Precedence
-
-Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts`
-
-| Case | Baseline available | Draft vs baseline | Expected mutation behavior |
-| --- | --- | --- | --- |
-| S1 | Yes | No changes | Skip all core-setting mutations. |
-| S2 | Yes | One field changed | Only that field's mutation runs. |
-| S3 | No | Draft populated | Apply trusted defaults + draft values (best-effort). |
-| S4 | No | Draft empty | Apply trusted defaults. |
-| S5 | Timed out before baseline ready | Baseline arrives later | Current behavior remains fallback path once timeout tripped. |
-
-#### B) Summary Step: Plugin Install Matrix
-
-Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts`
-
-| Case | Selected plugin state | Installer result | Expected behavior |
-| --- | --- | --- | --- |
-| P1 | Already installed | N/A | Skip install, show already installed state. |
-| P2 | Already installed (case/whitespace variants) | N/A | Skip due to normalized filename dedupe. |
-| P3 | Unknown plugin id | N/A | Skip install safely. |
-| P4 | Not installed | `SUCCEEDED` | Install logged as success. |
-| P5 | Not installed | `FAILED` | Warning path, continue flow. |
-| P6 | Not installed | timeout error | Timeout classification path. |
-
-#### C) Summary Step: Locale/Language Matrix
-
-Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts`
-
-| Case | Target locale | Language metadata | Installer result | Expected behavior |
-| --- | --- | --- | --- | --- |
-| L1 | `en_US` | N/A | N/A | Call `setLocale` directly, no pack install. |
-| L2 | non-default | present | `SUCCEEDED` | Install pack then call `setLocale`. |
-| L3 | non-default | missing | N/A | Skip locale switch, warning. |
-| L4 | non-default | present | `FAILED` | Keep current locale, warning. |
-| L5 | non-default | present | malformed/unknown status | Keep current locale, warning. |
-| L6 | non-default | present | timeout error | Timeout classification path. |
-
-#### D) Summary Step: Apply/Result Precedence Matrix
-
-Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts`
-
-| Case | Baseline status | Apply status | Other flags | Expected dialog class |
-| --- | --- | --- | --- | --- |
-| R1 | available | all apply operations succeed | none | Setup Applied |
-| R2 | unavailable | best-effort apply path | warnings | Best-Effort |
-| R3 | available | some apply operations warn/fail | warnings | Warnings |
-| R4 | available or unavailable | timeout during plugin/language install | timeout present | Timeout classification wins |
-| R5 | available | SSH verified | none | Fully applied path |
-| R6 | available | SSH update submitted | SSH unverified | Best-Effort |
-
-#### E) Summary Step: Apply Interaction Matrix
-
-Test file: `web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts`
-
-| Case | User action | Expected behavior |
-| --- | --- | --- |
-| A1 | Click Apply twice while first run in progress | Second click ignored; operations not duplicated; modal lock remains active. |
-
-#### F) Core Settings Step: Draft Ownership Matrix
-
-Test file: `web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts`
-
-| Case | `coreSettingsInitialized` | Baseline payload | Expected behavior |
-| --- | --- | --- | --- |
-| C1 | false | unavailable | Seed trusted defaults. |
-| C2 | false | invalid name/desc | Block submit on validation. |
-| C3 | false | valid data | Persist valid settings to draft. |
-| C4 | true | baseline has non-empty description | Keep intentionally empty draft description. |
-| C5 | true | baseline has timezone/theme/language | Keep intentionally empty initialized draft values. |
-| C6 | true | baseline has valid name | Keep initialized empty name invalid (do not auto-fix from baseline). |
-| C7 | false + onboarding completed=false | baseline timezone differs from browser | Prefer browser timezone over API baseline. |
-| C8 | false + onboarding completed=false | non-empty draft timezone + browser + API present | Prefer draft timezone. |
-| C9 | false + onboarding completed=true | browser timezone differs from API | Prefer API baseline timezone. |
-
-#### G) API Onboarding Service: Init Matrix
-
-Test file: `api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts`
-
-| Case | Input state | Expected behavior |
-| --- | --- | --- |
-| B1 | init called twice, tracker complete on second call | First applies, second skips (idempotent). |
-| B2 | banner copy fails + case model write fails | Continue flow; do not hard-stop chain. |
-| B3 | invalid activation code payload | No customizations applied. |
-
-#### H) Modal Bypass Matrix
-
-Test files:
-- `web/__test__/store/onboardingModalVisibility.test.ts`
-- `web/__test__/components/Onboarding/OnboardingModal.test.ts`
-
-| Case | Input | Expected behavior |
-| --- | --- | --- |
-| M1 | Keyboard shortcut entered | Set temporary bypass active for current session/boot; modal hides without completion mutation. |
-| M2 | `onboarding=bypass` in URL | Activate temporary bypass and remove param from URL. |
-| M3 | `onboarding=resume` in URL | Clear temporary bypass, force modal visibility path, remove param from URL. |
-| M4 | Temporary bypass active + upgrade onboarding pending | Modal remains hidden due bypass gate. |
-| M5 | Normal close path (no bypass trigger) | Existing completion behavior remains unchanged. |
-
-### How To Run The Targeted Suites
-
-From repo root:
-
-```bash
-pnpm --filter @unraid/web exec vitest run __test__/components/Onboarding/OnboardingSummaryStep.test.ts
-pnpm --filter @unraid/web exec vitest run __test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
-pnpm --filter @unraid/api exec vitest run src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
-```
From b72b9a943bfbeff12b490499fee8b439c29705e5 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 17:31:32 -0400
Subject: [PATCH 05/52] fix(onboarding-ui): show step overlay while transition
saves run
- Purpose: make transition-time saves visually obvious beyond the button spinner.
- Before: the primary button showed loading and the step controls were disabled, but the rest of the step stayed visually static during the save.
- Problem: users could miss that the wizard was actively persisting state, especially on Back/Skip where the button itself did not have its own spinner treatment.
- Now: the active step shows a compact loading overlay while transition saves are in flight, while preserving the existing full-modal loading state for bootstrap and closing.
Files changed in this commit:
- web/src/components/Onboarding/OnboardingModal.vue: added a compact step-level loading overlay that appears only during transition saves and reuses the existing onboarding loading component/text.
---
.../components/Onboarding/OnboardingModal.vue | 23 ++++++++++++++++++-
1 file changed, 22 insertions(+), 1 deletion(-)
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index 12b514768e..b8f006ffc1 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -544,6 +544,9 @@ 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')
);
@@ -932,7 +935,25 @@ const currentStepProps = computed>(() => {
-
+
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 = () => {
{{ lt('onboarding.licenseStep.actions.contactSupport', 'Having trouble? Contact Support') }}
+
+
{
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 = () => {
-->
+
+
t('onboarding.pluginsStep.nextStep'));
+
+
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);
-
+
+
+
@@ -603,14 +609,6 @@ const stepError = computed(() => error.value ?? props.saveError ?? null);
-
-
t('onboarding.internalBootStep.actions.
-
+
+
+
@@ -1181,14 +1187,6 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
{{ stepError }}
-
-
diff --git a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue
index d605c7db01..2036c9cc0c 100644
--- a/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingLicenseStep.vue
@@ -132,11 +132,20 @@ const doSkip = () => {
+
+
-
+
{
{{ lt('onboarding.licenseStep.actions.contactSupport', 'Having trouble? Contact Support') }}
-
-
{
+
+
-
+
@@ -297,14 +303,6 @@ const openDocs = () => {
-->
-
-
t('onboarding.pluginsStep.nextStep'));
-
+
+
+
@@ -251,14 +257,6 @@ const primaryButtonText = computed(() => t('onboarding.pluginsStep.nextStep'));
-
-
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index f1705655c8..8c5614ab98 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -1137,7 +1137,13 @@ const handleBack = () => {
-
+
+
+
@@ -1422,14 +1428,6 @@ const handleBack = () => {
-
-
Date: Tue, 31 Mar 2026 18:08:24 -0400
Subject: [PATCH 08/52] fix(onboarding-exit): clear server draft on modal exits
- Purpose:
Make every onboarding exit path clear the durable server-owned wizard draft so users do not come back to stale progress after leaving the modal.
- Before:
`completeOnboarding()` already cleared the tracker because completion resets wizard state, so dashboard exits and successful next-step finishes were mostly fine.
The explicit modal close path only changed visibility/completion flags and could leave the saved draft, navigation step, and internal-boot operational state behind.
The reboot and shutdown flows could also leave stale wizard state if `completeOnboarding()` failed before the power action continued.
- Problem:
Users who intentionally leave onboarding could reopen the wizard and see old draft data from a prior session even though they had already chosen to exit.
That made the server-owned draft behave more like sticky abandoned state than a draft tied to an active onboarding run.
- What changed:
Add `OnboardingTrackerService.clearWizardState()` to clear `draft`, `navigation`, and `internalBootState` while preserving completion metadata and force-open semantics.
Update `OnboardingService.closeOnboarding()` so non-completing close flows always wipe the wizard state after they finish their visibility bookkeeping.
Update the final Next Steps reboot/shutdown path so if `completeOnboarding()` fails but we still proceed with the power action, the web explicitly falls back to `closeOnboarding()` first to clear the server draft.
Add API and web tests covering the new clear-on-close behavior and the reboot/shutdown fallback path.
- How it works:
Completion exits still clear state through `markCompleted()`, which resets the tracker as part of completion.
Non-completion exits now use the new tracker clear helper, which preserves `completed`, `completedAtVersion`, and `forceOpen` while zeroing the wizard-specific state.
When reboot/shutdown continues after a completion error, the web now invokes the close mutation as a best-effort draft-clearing fallback before issuing the power command.
---
.../config/onboarding-tracker.service.spec.ts | 39 ++++++++++++++++++
.../config/onboarding-tracker.service.ts | 40 +++++++++++++++++++
.../customization/onboarding.service.spec.ts | 5 +++
.../customization/onboarding.service.ts | 3 ++
.../OnboardingNextStepsStep.test.ts | 9 +++++
.../steps/OnboardingNextStepsStep.vue | 8 ++++
6 files changed, 104 insertions(+)
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 923f44081b..ebdcc68593 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -95,6 +95,45 @@ describe('OnboardingTrackerService write retries', () => {
await expect(tracker.markCompleted()).rejects.toThrow('persistent-write-failure');
expect(mockAtomicWriteFile).toHaveBeenCalledTimes(3);
});
+
+ it('clears wizard state while preserving completion metadata', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: true,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ });
+ });
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(tracker.clearWizardState()).resolves.toEqual({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: true,
+ ...createEmptyWizardState(),
+ });
+ });
});
describe('OnboardingTrackerService tracker state availability', () => {
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index c6fbd3a40c..86e24d3d76 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -464,6 +464,46 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
this.bypassActive = active;
}
+ async clearWizardState(): Promise {
+ const overrideState = this.onboardingOverrides.getState();
+ if (overrideState?.onboarding !== undefined) {
+ this.state = {
+ ...this.state,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+
+ return this.getCachedState();
+ }
+
+ const currentStateResult = await this.getStateResult();
+ if (currentStateResult.kind === 'error') {
+ throw currentStateResult.error;
+ }
+
+ const currentState = currentStateResult.state;
+ const updatedState: TrackerState = {
+ completed: currentState.completed,
+ completedAtVersion: currentState.completedAtVersion,
+ forceOpen: currentState.forceOpen,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+
+ await this.writeTrackerState(updatedState);
+ this.syncConfig();
+
+ return this.getCachedState();
+ }
+
async saveDraft(input: SaveOnboardingDraftInput): Promise {
const currentStateResult = await this.getStateResult();
if (currentStateResult.kind === 'error') {
diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
index 620dac0548..af135f51a5 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
@@ -137,6 +137,7 @@ const onboardingTrackerMock = {
) => Promise<{ completed: boolean; completedAtVersion?: string; forceOpen: boolean }>
>(),
setBypassActive: vi.fn<(active: boolean) => void>(),
+ clearWizardState: vi.fn(),
saveDraft: vi.fn(),
};
const onboardingOverridesMock = {
@@ -589,6 +590,7 @@ describe('OnboardingService', () => {
await service.closeOnboarding();
expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(false);
+ expect(onboardingTrackerMock.clearWizardState).toHaveBeenCalledTimes(1);
});
it('marks incomplete onboarding complete when closed on supported versions', async () => {
@@ -607,6 +609,7 @@ describe('OnboardingService', () => {
expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1);
expect(onboardingTrackerMock.setForceOpen).not.toHaveBeenCalled();
+ expect(onboardingTrackerMock.clearWizardState).not.toHaveBeenCalled();
});
it('marks licensed incomplete onboarding complete when closed on supported versions', async () => {
@@ -629,6 +632,7 @@ describe('OnboardingService', () => {
expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1);
expect(onboardingTrackerMock.setForceOpen).not.toHaveBeenCalled();
+ expect(onboardingTrackerMock.clearWizardState).not.toHaveBeenCalled();
});
it('closes force-opened fresh incomplete onboarding in one action', async () => {
@@ -647,6 +651,7 @@ describe('OnboardingService', () => {
expect(onboardingTrackerMock.setForceOpen).toHaveBeenCalledWith(false);
expect(onboardingTrackerMock.markCompleted).toHaveBeenCalledTimes(1);
+ expect(onboardingTrackerMock.clearWizardState).not.toHaveBeenCalled();
});
it('enables the in-memory bypass', async () => {
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 220d6994b7..cc7a626690 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
@@ -571,7 +571,10 @@ export class OnboardingService implements OnModuleInit {
if (shouldAutoOpen) {
await this.onboardingTracker.markCompleted();
+ return;
}
+
+ await this.onboardingTracker.clearWizardState();
}
public async bypassOnboarding(): Promise {
diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
index 98f74c188b..a10793eea1 100644
--- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
@@ -3,6 +3,7 @@ import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { CLOSE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/closeOnboarding.mutation';
import { COMPLETE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/completeUpgradeStep.mutation';
import OnboardingNextStepsStep from '~/components/Onboarding/steps/OnboardingNextStepsStep.vue';
import { createTestI18n } from '../../utils/i18n';
@@ -14,6 +15,7 @@ const {
submitInternalBootShutdownMock,
cleanupOnboardingStorageMock,
completeOnboardingMock,
+ closeOnboardingMock,
refetchOnboardingMock,
useMutationMock,
} = vi.hoisted(() => ({
@@ -48,6 +50,7 @@ const {
submitInternalBootShutdownMock: vi.fn(),
cleanupOnboardingStorageMock: vi.fn(),
completeOnboardingMock: vi.fn().mockResolvedValue({}),
+ closeOnboardingMock: vi.fn().mockResolvedValue({}),
refetchOnboardingMock: vi.fn().mockResolvedValue({}),
useMutationMock: vi.fn(),
}));
@@ -97,11 +100,15 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootApplyAttempted = false;
draftStore.internalBootSelection = null;
completeOnboardingMock.mockResolvedValue({});
+ closeOnboardingMock.mockResolvedValue({});
refetchOnboardingMock.mockResolvedValue({});
useMutationMock.mockImplementation((doc: unknown) => {
if (doc === COMPLETE_ONBOARDING_MUTATION) {
return { mutate: completeOnboardingMock };
}
+ if (doc === CLOSE_ONBOARDING_MUTATION) {
+ return { mutate: closeOnboardingMock };
+ }
return { mutate: vi.fn() };
});
});
@@ -317,6 +324,7 @@ describe('OnboardingNextStepsStep', () => {
await flushPromises();
expect(completeOnboardingMock).toHaveBeenCalledTimes(1);
+ expect(closeOnboardingMock).toHaveBeenCalledTimes(1);
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
@@ -401,6 +409,7 @@ describe('OnboardingNextStepsStep', () => {
await confirmButton!.trigger('click');
await flushPromises();
+ expect(closeOnboardingMock).toHaveBeenCalledTimes(1);
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
diff --git a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
index 575a30dc1d..54a748c7cb 100644
--- a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue
@@ -22,6 +22,7 @@ import {
submitInternalBootReboot,
submitInternalBootShutdown,
} from '@/components/Onboarding/composables/internalBoot';
+import { CLOSE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/closeOnboarding.mutation';
import { COMPLETE_ONBOARDING_MUTATION } from '@/components/Onboarding/graphql/completeUpgradeStep.mutation';
import { useActivationCodeDataStore } from '@/components/Onboarding/store/activationCodeData';
import { useOnboardingStore } from '@/components/Onboarding/store/onboardingStatus';
@@ -44,6 +45,7 @@ const props = defineProps();
const { t } = useI18n();
const store = useActivationCodeDataStore();
const { mutate: completeOnboarding } = useMutation(COMPLETE_ONBOARDING_MUTATION);
+const { mutate: closeOnboarding } = useMutation(CLOSE_ONBOARDING_MUTATION);
const { refetchOnboarding } = useOnboardingStore();
const partnerInfo = computed(() => store.partnerInfo);
@@ -133,6 +135,12 @@ const finishOnboarding = async ({ action }: { action?: 'reboot' | 'shutdown' } =
isCompleting.value = false;
return;
}
+
+ try {
+ await closeOnboarding();
+ } catch (closeError: unknown) {
+ console.error('Failed to clear onboarding state before power action:', closeError);
+ }
}
cleanupOnboardingStorage();
From 5159467e34fcf168c8b346e980e3d4a3e57fb7f6 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 18:32:13 -0400
Subject: [PATCH 09/52] fix(onboarding-core): gate core settings on live
baseline
- Purpose: make the Core Settings step wait for its initial live server baseline before showing editable fields when no server draft exists.
- Before: the onboarding modal bootstrap decided whether the wizard opened, but the Core Settings form could still render before GET_CORE_SETTINGS_QUERY and onboarding status finished settling.
- Problem: users could briefly see browser-seeded defaults without clear context, and a failed live query had no explicit retry path or explanation of which values were winning.
- Change: add a full-step loading state while the initial live baseline is still pending, add a retryable warning when the live query fails without a draft, and keep the form editable with safe defaults after the warning appears.
- How it works: the step now waits on onboarding status plus GET_CORE_SETTINGS_QUERY only when there is no draft, treats saved-transition loading separately, and refetches the core settings query when the retry button is pressed.
- Tests: extend the Core Settings component suite to cover the blocked-loading path, retry-on-query-failure behavior, and the updated timezone/identity precedence once onboarding status settles.
- Docs: update the onboarding wizard documentation with step-local query behavior and a concrete precedence explanation for server identity and timezone selection, including fresh-setup browser-timezone preference versus returning-setup server-timezone preference.
---
.../OnboardingCoreSettingsStep.test.ts | 115 ++++++++++++++++--
.../Onboarding/ONBOARDING_WIZARD.md | 50 ++++++++
.../steps/OnboardingCoreSettingsStep.vue | 59 +++++++--
3 files changed, 205 insertions(+), 19 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index 29fcff7c71..8d5bd68a0d 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -18,6 +18,9 @@ const {
languagesError,
coreOnResultHandlers,
coreSettingsResult,
+ coreSettingsLoading,
+ coreSettingsError,
+ refetchCoreSettingsMock,
useQueryMock,
} = vi.hoisted(() => ({
setCoreSettingsMock: vi.fn(),
@@ -57,12 +60,18 @@ const {
languagesError: { value: null as unknown },
coreOnResultHandlers: [] as Array<(payload: unknown) => void>,
coreSettingsResult: { value: null as unknown },
+ coreSettingsLoading: { value: false },
+ coreSettingsError: { value: null as unknown },
+ refetchCoreSettingsMock: vi.fn().mockResolvedValue({}),
useQueryMock: vi.fn(),
}));
draftStore.setCoreSettings = setCoreSettingsMock;
vi.mock('@unraid/ui', () => ({
+ Spinner: {
+ template: '
',
+ },
BrandButton: {
props: ['text', 'disabled'],
emits: ['click'],
@@ -109,6 +118,9 @@ const setupQueryMocks = () => {
if (doc === GET_CORE_SETTINGS_QUERY) {
return {
result: coreSettingsResult,
+ loading: coreSettingsLoading,
+ error: coreSettingsError,
+ refetch: refetchCoreSettingsMock,
onResult: (cb: (payload: unknown) => void) => {
coreOnResultHandlers.push((payload: unknown) => {
const candidate = payload as { data?: unknown };
@@ -150,6 +162,9 @@ const mountComponent = (props: Record = {}) => {
global: {
plugins: [createTestI18n()],
stubs: {
+ UAlert: {
+ template: '
',
+ },
USelectMenu: {
props: ['modelValue', 'items', 'disabled'],
emits: ['update:modelValue'],
@@ -200,11 +215,42 @@ describe('OnboardingCoreSettingsStep', () => {
onboardingStore.completed.value = true;
onboardingStore.loading.value = false;
coreSettingsResult.value = null;
+ coreSettingsLoading.value = false;
+ coreSettingsError.value = null;
+ refetchCoreSettingsMock.mockResolvedValue({});
languagesLoading.value = false;
languagesError.value = null;
});
+ it('shows a full-step loading state while waiting for the initial server baseline', async () => {
+ coreSettingsLoading.value = true;
+
+ const { wrapper } = mountComponent();
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="brand-button"]').exists()).toBe(false);
+ });
+
+ it('shows a retryable warning when the core settings query fails without a draft', async () => {
+ coreSettingsError.value = new Error('offline');
+
+ const { wrapper } = mountComponent();
+ await flushPromises();
+
+ expect(wrapper.text()).toContain("We couldn't load your current server settings.");
+ const retryButton = wrapper
+ .findAll('button')
+ .find((candidate) => candidate.text().trim() === 'Retry');
+ expect(retryButton).toBeTruthy();
+
+ await retryButton!.trigger('click');
+ await flushPromises();
+
+ expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1);
+ });
+
it('prefers browser timezone over API on initial setup when draft timezone is empty', async () => {
onboardingStore.completed.value = false;
@@ -227,7 +273,9 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
+ onboardingStore.loading.value = false;
await flushPromises();
+ await wrapper.vm.$nextTick();
const submitButton = wrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
@@ -298,6 +346,7 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
+ onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -321,11 +370,11 @@ describe('OnboardingCoreSettingsStep', () => {
}) as Intl.DateTimeFormat
);
- const { wrapper, onComplete } = mountComponent();
+ const { wrapper } = mountComponent();
await flushPromises();
- const coreOnResult = coreOnResultHandlers[0];
- coreOnResult({
+ const initialCoreOnResult = coreOnResultHandlers[0];
+ initialCoreOnResult({
data: {
server: { name: 'Tower', comment: '' },
vars: { name: 'Tower', useSsh: false, localTld: 'local' },
@@ -335,7 +384,25 @@ describe('OnboardingCoreSettingsStep', () => {
});
await flushPromises();
- const submitButton = wrapper.find('[data-testid="brand-button"]');
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ wrapper.unmount();
+
+ onboardingStore.loading.value = false;
+ const { wrapper: settledWrapper, onComplete } = mountComponent();
+ await flushPromises();
+
+ const settledCoreOnResult = coreOnResultHandlers[1];
+ settledCoreOnResult({
+ data: {
+ server: { name: 'Tower', comment: '' },
+ vars: { name: 'Tower', useSsh: false, localTld: 'local' },
+ display: { theme: 'white', locale: 'en_US' },
+ systemTime: { timeZone: 'UTC' },
+ },
+ });
+ await flushPromises();
+
+ const submitButton = settledWrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
await flushPromises();
@@ -365,6 +432,7 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
+ onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -399,6 +467,7 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
+ onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -433,6 +502,7 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
+ onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -447,15 +517,15 @@ describe('OnboardingCoreSettingsStep', () => {
expect(onComplete).toHaveBeenCalledTimes(1);
});
- it('uses API identity while onboarding tracker state is still loading', async () => {
+ it('uses activation identity once onboarding tracker loading resolves', async () => {
onboardingStore.loading.value = true;
onboardingStore.completed.value = false;
- const { wrapper, onComplete } = mountComponent();
+ const { wrapper } = mountComponent();
await flushPromises();
- const coreOnResult = coreOnResultHandlers[0];
- coreOnResult({
+ const initialCoreOnResult = coreOnResultHandlers[0];
+ initialCoreOnResult({
data: {
customization: {
activationCode: {
@@ -470,14 +540,37 @@ describe('OnboardingCoreSettingsStep', () => {
});
await flushPromises();
- const submitButton = wrapper.find('[data-testid="brand-button"]');
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ wrapper.unmount();
+
+ onboardingStore.loading.value = false;
+ const { wrapper: settledWrapper, onComplete } = mountComponent();
+ await flushPromises();
+
+ const settledCoreOnResult = coreOnResultHandlers[1];
+ settledCoreOnResult({
+ data: {
+ customization: {
+ activationCode: {
+ system: { serverName: 'Server01', comment: 'Partner-provided comment' },
+ },
+ },
+ server: { name: 'TowerFromServer', comment: 'Comment from API' },
+ vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' },
+ display: { theme: 'white', locale: 'en_US' },
+ systemTime: { timeZone: 'UTC' },
+ },
+ });
+ await flushPromises();
+
+ const submitButton = settledWrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
await flushPromises();
expect(setCoreSettingsMock).toHaveBeenCalledTimes(1);
expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({
- serverName: 'TowerFromServer',
- serverDescription: 'Comment from API',
+ serverName: 'Server01',
+ serverDescription: 'Partner-provided comment',
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
index 6af5dff616..e350e8e1d9 100644
--- a/web/src/components/Onboarding/ONBOARDING_WIZARD.md
+++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
@@ -86,6 +86,56 @@ The old Pinia draft store was removed:
- `web/src/components/Onboarding/store/onboardingDraft.ts`
+### Step Queries
+
+Not every step relies only on bootstrap data.
+
+- Bootstrap decides whether onboarding opens and hydrates the server-owned wizard draft.
+- Individual steps can still run live step-specific queries after they mount.
+
+Current step-local query behavior:
+
+- `CONFIGURE_SETTINGS`
+ - runs `GET_CORE_SETTINGS_QUERY` for live server name, description, SSH, theme, language, and server timezone
+ - shows a full-step loading state while that initial live baseline is still pending and there is no draft yet
+ - if that live query fails and there is no draft, the step shows a retryable warning and falls back to editable defaults
+- `ADD_PLUGINS`
+ - runs `INSTALLED_UNRAID_PLUGINS_QUERY`
+ - shows a step-local loading state while installed plugin data is pending
+- `CONFIGURE_BOOT`
+ - runs `GetInternalBootContextDocument`
+ - shows a loading state while the internal-boot context is pending
+ - shows a step-local error state if that query fails
+- `SUMMARY`
+ - runs read-only baseline queries used for the final apply step
+ - does not block rendering, but surfaces readiness warnings if baseline data is unavailable
+
+Steps like `OVERVIEW`, `ACTIVATE_LICENSE`, and `NEXT_STEPS` do not wait on an extra step-local bootstrap-sized query before rendering.
+
+### Core Settings Precedence
+
+Core Settings uses both bootstrap draft state and a live server query. The precedence is intentional.
+
+For server name and description:
+
+- if `initialDraft` exists, use the draft
+- otherwise use live server data from `GET_CORE_SETTINGS_QUERY`
+- on fresh setup with activation metadata, prefer activation identity over the generic API identity
+
+For timezone:
+
+- if draft timezone exists, use draft timezone
+- otherwise wait until onboarding status and the live core-settings query have both settled
+- on fresh setup, prefer browser timezone if there is no draft timezone
+- if browser timezone is unavailable on fresh setup, fall back to the server timezone
+- on non-fresh setup, prefer the server timezone
+
+Why onboarding status matters here:
+
+- timezone precedence is different for fresh setup vs non-fresh setup
+- fresh setup prefers browser timezone if there is no draft
+- non-fresh setup prefers the server timezone
+
## Saving Rules
The wizard saves only on step transitions.
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index c3a58739e9..7bdf0a23ed 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -79,15 +79,19 @@ const currentHostname = computed(() => {
const isSaving = ref(false);
const error = ref(null);
+const coreSettingsLoadErrorMessage =
+ "We couldn't load your current server settings. You can retry or continue with default values.";
const { result: timeZoneOptionsResult } = useQuery(TIME_ZONE_OPTIONS_QUERY);
-const { result: coreSettingsResult, onResult: onCoreSettingsResult } = useQuery(
- GET_CORE_SETTINGS_QUERY,
- null,
- {
- fetchPolicy: 'network-only',
- }
-);
+const {
+ result: coreSettingsResult,
+ onResult: onCoreSettingsResult,
+ loading: coreSettingsLoading,
+ error: coreSettingsError,
+ refetch: refetchCoreSettings,
+} = useQuery(GET_CORE_SETTINGS_QUERY, null, {
+ fetchPolicy: 'network-only',
+});
type CoreSettingsIdentityData = {
server?: {
@@ -413,12 +417,28 @@ const serverDescriptionValidation = computed(() => {
const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
const stepError = computed(() => error.value ?? props.saveError ?? null);
+const isAwaitingInitialServerBaseline = computed(
+ () =>
+ !props.initialDraft &&
+ (onboardingLoading.value || (coreSettingsLoading.value && !coreSettingsResult.value))
+);
+const showCoreSettingsLoadWarning = computed(
+ () => !props.initialDraft && Boolean(coreSettingsError.value)
+);
+
+const handleRetryCoreSettings = async () => {
+ try {
+ await refetchCoreSettings();
+ } catch (queryError) {
+ console.error('Failed to refetch onboarding core settings:', queryError);
+ }
+};
@@ -460,6 +480,29 @@ const stepError = computed(() => error.value ?? props.saveError ?? null);
+
+
+
+
+ {{ coreSettingsLoadErrorMessage }}
+
+
+ {{ t('common.retry') }}
+
+
+
+
+
From 6017462abe8924e7ed425fcf090a301091786570 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 18:36:51 -0400
Subject: [PATCH 10/52] revert(onboarding-core): remove baseline gating
follow-up
- Purpose: remove the Core Settings live-baseline gating follow-up because it is unnecessary for this PR and broadens scope beyond the server-owned onboarding work.
- Before: the follow-up commit added a new full-step loading gate for GET_CORE_SETTINGS_QUERY, a retryable warning state when that query failed without a draft, and extra documentation and tests describing that behavior.
- Problem: those changes were a UX refinement on top of the onboarding rewrite rather than a required part of the server-side persistence migration, so they made the PR noisier and harder to review.
- Change: restore the previous Core Settings rendering behavior, remove the added retry warning and query-blocking gate, and drop the matching doc/test additions.
- How it works: the step now goes back to using only the existing transition-save loading state, while the docs return to describing the shipped server-owned wizard flow without the extra step-query guidance from that follow-up.
- Verification: reran the focused Core Settings component test suite and web type-check after the revert to confirm the onboarding web package is back in a clean state.
---
.../OnboardingCoreSettingsStep.test.ts | 115 ++----------------
.../Onboarding/ONBOARDING_WIZARD.md | 50 --------
.../steps/OnboardingCoreSettingsStep.vue | 59 ++-------
3 files changed, 19 insertions(+), 205 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index 8d5bd68a0d..29fcff7c71 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -18,9 +18,6 @@ const {
languagesError,
coreOnResultHandlers,
coreSettingsResult,
- coreSettingsLoading,
- coreSettingsError,
- refetchCoreSettingsMock,
useQueryMock,
} = vi.hoisted(() => ({
setCoreSettingsMock: vi.fn(),
@@ -60,18 +57,12 @@ const {
languagesError: { value: null as unknown },
coreOnResultHandlers: [] as Array<(payload: unknown) => void>,
coreSettingsResult: { value: null as unknown },
- coreSettingsLoading: { value: false },
- coreSettingsError: { value: null as unknown },
- refetchCoreSettingsMock: vi.fn().mockResolvedValue({}),
useQueryMock: vi.fn(),
}));
draftStore.setCoreSettings = setCoreSettingsMock;
vi.mock('@unraid/ui', () => ({
- Spinner: {
- template: '
',
- },
BrandButton: {
props: ['text', 'disabled'],
emits: ['click'],
@@ -118,9 +109,6 @@ const setupQueryMocks = () => {
if (doc === GET_CORE_SETTINGS_QUERY) {
return {
result: coreSettingsResult,
- loading: coreSettingsLoading,
- error: coreSettingsError,
- refetch: refetchCoreSettingsMock,
onResult: (cb: (payload: unknown) => void) => {
coreOnResultHandlers.push((payload: unknown) => {
const candidate = payload as { data?: unknown };
@@ -162,9 +150,6 @@ const mountComponent = (props: Record = {}) => {
global: {
plugins: [createTestI18n()],
stubs: {
- UAlert: {
- template: '
',
- },
USelectMenu: {
props: ['modelValue', 'items', 'disabled'],
emits: ['update:modelValue'],
@@ -215,42 +200,11 @@ describe('OnboardingCoreSettingsStep', () => {
onboardingStore.completed.value = true;
onboardingStore.loading.value = false;
coreSettingsResult.value = null;
- coreSettingsLoading.value = false;
- coreSettingsError.value = null;
- refetchCoreSettingsMock.mockResolvedValue({});
languagesLoading.value = false;
languagesError.value = null;
});
- it('shows a full-step loading state while waiting for the initial server baseline', async () => {
- coreSettingsLoading.value = true;
-
- const { wrapper } = mountComponent();
- await flushPromises();
-
- expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
- expect(wrapper.find('[data-testid="brand-button"]').exists()).toBe(false);
- });
-
- it('shows a retryable warning when the core settings query fails without a draft', async () => {
- coreSettingsError.value = new Error('offline');
-
- const { wrapper } = mountComponent();
- await flushPromises();
-
- expect(wrapper.text()).toContain("We couldn't load your current server settings.");
- const retryButton = wrapper
- .findAll('button')
- .find((candidate) => candidate.text().trim() === 'Retry');
- expect(retryButton).toBeTruthy();
-
- await retryButton!.trigger('click');
- await flushPromises();
-
- expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1);
- });
-
it('prefers browser timezone over API on initial setup when draft timezone is empty', async () => {
onboardingStore.completed.value = false;
@@ -273,9 +227,7 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
- onboardingStore.loading.value = false;
await flushPromises();
- await wrapper.vm.$nextTick();
const submitButton = wrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
@@ -346,7 +298,6 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
- onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -370,29 +321,11 @@ describe('OnboardingCoreSettingsStep', () => {
}) as Intl.DateTimeFormat
);
- const { wrapper } = mountComponent();
- await flushPromises();
-
- const initialCoreOnResult = coreOnResultHandlers[0];
- initialCoreOnResult({
- data: {
- server: { name: 'Tower', comment: '' },
- vars: { name: 'Tower', useSsh: false, localTld: 'local' },
- display: { theme: 'white', locale: 'en_US' },
- systemTime: { timeZone: 'UTC' },
- },
- });
- await flushPromises();
-
- expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
- wrapper.unmount();
-
- onboardingStore.loading.value = false;
- const { wrapper: settledWrapper, onComplete } = mountComponent();
+ const { wrapper, onComplete } = mountComponent();
await flushPromises();
- const settledCoreOnResult = coreOnResultHandlers[1];
- settledCoreOnResult({
+ const coreOnResult = coreOnResultHandlers[0];
+ coreOnResult({
data: {
server: { name: 'Tower', comment: '' },
vars: { name: 'Tower', useSsh: false, localTld: 'local' },
@@ -402,7 +335,7 @@ describe('OnboardingCoreSettingsStep', () => {
});
await flushPromises();
- const submitButton = settledWrapper.find('[data-testid="brand-button"]');
+ const submitButton = wrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
await flushPromises();
@@ -432,7 +365,6 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
- onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -467,7 +399,6 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
- onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -502,7 +433,6 @@ describe('OnboardingCoreSettingsStep', () => {
systemTime: { timeZone: 'UTC' },
},
});
- onboardingStore.loading.value = false;
await flushPromises();
const submitButton = wrapper.find('[data-testid="brand-button"]');
@@ -517,38 +447,15 @@ describe('OnboardingCoreSettingsStep', () => {
expect(onComplete).toHaveBeenCalledTimes(1);
});
- it('uses activation identity once onboarding tracker loading resolves', async () => {
+ it('uses API identity while onboarding tracker state is still loading', async () => {
onboardingStore.loading.value = true;
onboardingStore.completed.value = false;
- const { wrapper } = mountComponent();
- await flushPromises();
-
- const initialCoreOnResult = coreOnResultHandlers[0];
- initialCoreOnResult({
- data: {
- customization: {
- activationCode: {
- system: { serverName: 'Server01', comment: 'Partner-provided comment' },
- },
- },
- server: { name: 'TowerFromServer', comment: 'Comment from API' },
- vars: { name: 'TowerFromVars', useSsh: false, localTld: 'local' },
- display: { theme: 'white', locale: 'en_US' },
- systemTime: { timeZone: 'UTC' },
- },
- });
- await flushPromises();
-
- expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
- wrapper.unmount();
-
- onboardingStore.loading.value = false;
- const { wrapper: settledWrapper, onComplete } = mountComponent();
+ const { wrapper, onComplete } = mountComponent();
await flushPromises();
- const settledCoreOnResult = coreOnResultHandlers[1];
- settledCoreOnResult({
+ const coreOnResult = coreOnResultHandlers[0];
+ coreOnResult({
data: {
customization: {
activationCode: {
@@ -563,14 +470,14 @@ describe('OnboardingCoreSettingsStep', () => {
});
await flushPromises();
- const submitButton = settledWrapper.find('[data-testid="brand-button"]');
+ const submitButton = wrapper.find('[data-testid="brand-button"]');
await submitButton.trigger('click');
await flushPromises();
expect(setCoreSettingsMock).toHaveBeenCalledTimes(1);
expect(setCoreSettingsMock.mock.calls[0][0]).toMatchObject({
- serverName: 'Server01',
- serverDescription: 'Partner-provided comment',
+ serverName: 'TowerFromServer',
+ serverDescription: 'Comment from API',
});
expect(onComplete).toHaveBeenCalledTimes(1);
});
diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
index e350e8e1d9..6af5dff616 100644
--- a/web/src/components/Onboarding/ONBOARDING_WIZARD.md
+++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
@@ -86,56 +86,6 @@ The old Pinia draft store was removed:
- `web/src/components/Onboarding/store/onboardingDraft.ts`
-### Step Queries
-
-Not every step relies only on bootstrap data.
-
-- Bootstrap decides whether onboarding opens and hydrates the server-owned wizard draft.
-- Individual steps can still run live step-specific queries after they mount.
-
-Current step-local query behavior:
-
-- `CONFIGURE_SETTINGS`
- - runs `GET_CORE_SETTINGS_QUERY` for live server name, description, SSH, theme, language, and server timezone
- - shows a full-step loading state while that initial live baseline is still pending and there is no draft yet
- - if that live query fails and there is no draft, the step shows a retryable warning and falls back to editable defaults
-- `ADD_PLUGINS`
- - runs `INSTALLED_UNRAID_PLUGINS_QUERY`
- - shows a step-local loading state while installed plugin data is pending
-- `CONFIGURE_BOOT`
- - runs `GetInternalBootContextDocument`
- - shows a loading state while the internal-boot context is pending
- - shows a step-local error state if that query fails
-- `SUMMARY`
- - runs read-only baseline queries used for the final apply step
- - does not block rendering, but surfaces readiness warnings if baseline data is unavailable
-
-Steps like `OVERVIEW`, `ACTIVATE_LICENSE`, and `NEXT_STEPS` do not wait on an extra step-local bootstrap-sized query before rendering.
-
-### Core Settings Precedence
-
-Core Settings uses both bootstrap draft state and a live server query. The precedence is intentional.
-
-For server name and description:
-
-- if `initialDraft` exists, use the draft
-- otherwise use live server data from `GET_CORE_SETTINGS_QUERY`
-- on fresh setup with activation metadata, prefer activation identity over the generic API identity
-
-For timezone:
-
-- if draft timezone exists, use draft timezone
-- otherwise wait until onboarding status and the live core-settings query have both settled
-- on fresh setup, prefer browser timezone if there is no draft timezone
-- if browser timezone is unavailable on fresh setup, fall back to the server timezone
-- on non-fresh setup, prefer the server timezone
-
-Why onboarding status matters here:
-
-- timezone precedence is different for fresh setup vs non-fresh setup
-- fresh setup prefers browser timezone if there is no draft
-- non-fresh setup prefers the server timezone
-
## Saving Rules
The wizard saves only on step transitions.
diff --git a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
index 7bdf0a23ed..c3a58739e9 100644
--- a/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue
@@ -79,19 +79,15 @@ const currentHostname = computed(() => {
const isSaving = ref(false);
const error = ref(null);
-const coreSettingsLoadErrorMessage =
- "We couldn't load your current server settings. You can retry or continue with default values.";
const { result: timeZoneOptionsResult } = useQuery(TIME_ZONE_OPTIONS_QUERY);
-const {
- result: coreSettingsResult,
- onResult: onCoreSettingsResult,
- loading: coreSettingsLoading,
- error: coreSettingsError,
- refetch: refetchCoreSettings,
-} = useQuery(GET_CORE_SETTINGS_QUERY, null, {
- fetchPolicy: 'network-only',
-});
+const { result: coreSettingsResult, onResult: onCoreSettingsResult } = useQuery(
+ GET_CORE_SETTINGS_QUERY,
+ null,
+ {
+ fetchPolicy: 'network-only',
+ }
+);
type CoreSettingsIdentityData = {
server?: {
@@ -417,28 +413,12 @@ const serverDescriptionValidation = computed(() => {
const isBusy = computed(() => isSaving.value || (props.isSavingStep ?? false));
const stepError = computed(() => error.value ?? props.saveError ?? null);
-const isAwaitingInitialServerBaseline = computed(
- () =>
- !props.initialDraft &&
- (onboardingLoading.value || (coreSettingsLoading.value && !coreSettingsResult.value))
-);
-const showCoreSettingsLoadWarning = computed(
- () => !props.initialDraft && Boolean(coreSettingsError.value)
-);
-
-const handleRetryCoreSettings = async () => {
- try {
- await refetchCoreSettings();
- } catch (queryError) {
- console.error('Failed to refetch onboarding core settings:', queryError);
- }
-};
@@ -480,29 +460,6 @@ const handleRetryCoreSettings = async () => {
-
-
-
-
- {{ coreSettingsLoadErrorMessage }}
-
-
- {{ t('common.retry') }}
-
-
-
-
-
From 9dec20bb32d4fda3a372d004aab31d69e68a7509 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Tue, 31 Mar 2026 19:56:01 -0400
Subject: [PATCH 11/52] feat(onboarding-boot): persist device metadata in
wizard draft
- Purpose of the change
Persist selected internal boot device metadata in the server-owned onboarding draft so every review surface can render the same boot summary without re-querying disk labels.
- How behavior was before
The internal boot step only saved device ids in the draft.
The onboarding Summary step compensated by running a separate internal boot context query and enriching labels after mount.
The standalone internal boot summary did not do that enrichment, so it showed raw ids only.
- Why that was a problem
The onboarding Summary step flickered from a raw serial/id label to a richer label a moment later.
The two summary experiences disagreed with each other.
The draft was not the real source of truth for what the user selected, which made resume and review behavior feel inconsistent.
- What the new change accomplishes
Selected devices are now stored as objects with id, sizeBytes, and deviceName across tracker state, GraphQL input/output, generated types, and web draft state.
Both onboarding summaries and the boot-drive warning dialog now render from the saved draft immediately.
The shared boot summary presentation is reused by both the full onboarding Summary step and the standalone internal boot wizard.
- How it works
Adds typed internal boot device models to the tracker, GraphQL schema/models, generated API and web GraphQL types, and local onboarding draft state.
Updates the internal boot step to save device metadata directly from the already-fetched assignable disk context while still sending bootSizeMiB to cmdMakeBootable for emcmd operations.
Extracts a shared boot summary helper and component, formats sizeBytes into the existing metric GB/TB labels, removes the Summary-step label-enrichment query path, and expands focused API and web tests to cover the new persistence and rendering flow.
---
api/generated-schema.graphql | 18 +-
api/src/unraid-api/cli/generated/graphql.ts | 17 +-
.../config/onboarding-tracker.model.ts | 8 +-
.../config/onboarding-tracker.service.spec.ts | 20 +-
.../config/onboarding-tracker.service.ts | 34 ++-
.../customization/activation-code.model.ts | 17 +-
.../customization/onboarding.service.spec.ts | 16 +-
.../customization/onboarding.service.ts | 15 +-
.../resolvers/onboarding/onboarding.model.ts | 26 +-
...OnboardingBootConfigurationSummary.test.ts | 199 ++++++++++++++++
.../OnboardingInternalBootStandalone.test.ts | 57 ++++-
.../OnboardingInternalBootStep.test.ts | 2 +-
.../Onboarding/OnboardingModal.test.ts | 2 +-
.../OnboardingNextStepsStep.test.ts | 24 +-
.../Onboarding/OnboardingSummaryStep.test.ts | 47 +++-
web/components.d.ts | 1 +
.../components/Onboarding/OnboardingModal.vue | 16 +-
.../OnboardingBootConfigurationSummary.vue | 47 ++++
.../buildBootConfigurationSummaryViewModel.ts | 222 ++++++++++++++++++
.../graphql/onboardingBootstrap.query.ts | 6 +-
.../Onboarding/onboardingWizardState.ts | 10 +-
.../OnboardingInternalBoot.standalone.vue | 76 ++++--
.../steps/OnboardingInternalBootStep.vue | 41 +++-
.../steps/OnboardingSummaryStep.vue | 211 ++++-------------
web/src/composables/gql/gql.ts | 6 +-
web/src/composables/gql/graphql.ts | 21 +-
26 files changed, 918 insertions(+), 241 deletions(-)
create mode 100644 web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts
create mode 100644 web/src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue
create mode 100644 web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql
index 6d418dfab2..cde29c8be8 100644
--- a/api/generated-schema.graphql
+++ b/api/generated-schema.graphql
@@ -1040,10 +1040,16 @@ type OnboardingWizardPluginsDraft {
selectedIds: [String!]!
}
+type OnboardingWizardInternalBootDevice {
+ id: String!
+ sizeBytes: BigInt!
+ deviceName: String!
+}
+
type OnboardingWizardInternalBootSelection {
poolName: String
slotCount: Int
- devices: [String!]!
+ devices: [OnboardingWizardInternalBootDevice!]!
bootSizeMiB: Int
updateBios: Boolean
poolMode: OnboardingWizardPoolMode
@@ -1630,10 +1636,16 @@ input OnboardingWizardInternalBootDraftInput {
selection: OnboardingWizardInternalBootSelectionInput
}
+input OnboardingWizardInternalBootDeviceInput {
+ id: String!
+ sizeBytes: BigInt!
+ deviceName: String!
+}
+
input OnboardingWizardInternalBootSelectionInput {
poolName: String
slotCount: Int
- devices: [String!]
+ devices: [OnboardingWizardInternalBootDeviceInput!]
bootSizeMiB: Int
updateBios: Boolean
poolMode: OnboardingWizardPoolMode
@@ -3752,4 +3764,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 d2c040fe29..0f13fbb5ee 100644
--- a/api/src/unraid-api/cli/generated/graphql.ts
+++ b/api/src/unraid-api/cli/generated/graphql.ts
@@ -2156,6 +2156,19 @@ export type OnboardingWizardDraftInput = {
plugins?: InputMaybe;
};
+export type OnboardingWizardInternalBootDevice = {
+ __typename?: 'OnboardingWizardInternalBootDevice';
+ deviceName: Scalars['String']['output'];
+ id: Scalars['String']['output'];
+ sizeBytes: Scalars['BigInt']['output'];
+};
+
+export type OnboardingWizardInternalBootDeviceInput = {
+ deviceName: Scalars['String']['input'];
+ id: Scalars['String']['input'];
+ sizeBytes: Scalars['BigInt']['input'];
+};
+
export type OnboardingWizardInternalBootDraft = {
__typename?: 'OnboardingWizardInternalBootDraft';
bootMode?: Maybe;
@@ -2172,7 +2185,7 @@ export type OnboardingWizardInternalBootDraftInput = {
export type OnboardingWizardInternalBootSelection = {
__typename?: 'OnboardingWizardInternalBootSelection';
bootSizeMiB?: Maybe;
- devices: Array;
+ devices: Array;
poolMode?: Maybe;
poolName?: Maybe;
slotCount?: Maybe;
@@ -2181,7 +2194,7 @@ export type OnboardingWizardInternalBootSelection = {
export type OnboardingWizardInternalBootSelectionInput = {
bootSizeMiB?: InputMaybe;
- devices?: InputMaybe>;
+ devices?: InputMaybe>;
poolMode?: InputMaybe;
poolName?: InputMaybe;
slotCount?: InputMaybe;
diff --git a/api/src/unraid-api/config/onboarding-tracker.model.ts b/api/src/unraid-api/config/onboarding-tracker.model.ts
index eba153ac48..34ea97ca69 100644
--- a/api/src/unraid-api/config/onboarding-tracker.model.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.model.ts
@@ -14,10 +14,16 @@ export type OnboardingPoolMode = 'dedicated' | 'hybrid';
export type OnboardingBootMode = 'usb' | 'storage';
+export type OnboardingInternalBootDevice = {
+ id: string;
+ sizeBytes: number;
+ deviceName: string;
+};
+
export type OnboardingInternalBootSelection = {
poolName?: string;
slotCount?: number;
- devices?: string[];
+ devices?: OnboardingInternalBootDevice[];
bootSizeMiB?: number;
updateBios?: boolean;
poolMode?: OnboardingPoolMode;
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 ebdcc68593..870f2cf81c 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -42,6 +42,12 @@ const createConfigService = (dataDir = '/tmp/unraid-data') => {
} as unknown as ConfigService;
};
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
describe('OnboardingTrackerService write retries', () => {
beforeEach(() => {
mockReadFile.mockReset();
@@ -358,7 +364,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['disk1'],
+ devices: [createBootDevice('disk1', 500_000_000_000, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'dedicated',
@@ -386,7 +392,7 @@ describe('OnboardingTrackerService tracker state availability', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['disk1'],
+ devices: [createBootDevice('disk1', 500_000_000_000, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'dedicated',
@@ -435,7 +441,10 @@ describe('OnboardingTrackerService tracker state availability', () => {
selection: {
poolName: 'cache',
slotCount: 2,
- devices: ['disk1', 'disk2'],
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ ],
bootSizeMiB: 32768,
updateBios: false,
poolMode: 'hybrid',
@@ -474,7 +483,10 @@ describe('OnboardingTrackerService tracker state availability', () => {
selection: {
poolName: 'cache',
slotCount: 2,
- devices: ['disk1', 'disk2'],
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ ],
bootSizeMiB: 32768,
updateBios: false,
poolMode: 'hybrid',
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index 86e24d3d76..972c1d6c8e 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -9,6 +9,7 @@ import type {
OnboardingBootMode,
OnboardingCoreSettingsDraft,
OnboardingDraft,
+ OnboardingInternalBootDevice,
OnboardingInternalBootDraft,
OnboardingInternalBootSelection,
OnboardingInternalBootState,
@@ -83,6 +84,37 @@ const normalizeStringArray = (value: unknown): string[] => {
return value.filter((item): item is string => typeof item === 'string');
};
+const normalizeBootDevice = (value: unknown): OnboardingInternalBootDevice | null => {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const candidate = value as Record;
+ const id = normalizeString(candidate.id);
+ const deviceName = normalizeString(candidate.deviceName);
+ const parsedSizeBytes = Number(candidate.sizeBytes);
+
+ if (!id || !deviceName || !Number.isFinite(parsedSizeBytes) || parsedSizeBytes <= 0) {
+ return null;
+ }
+
+ return {
+ id,
+ sizeBytes: parsedSizeBytes,
+ deviceName,
+ };
+};
+
+const normalizeBootDeviceArray = (value: unknown): OnboardingInternalBootDevice[] => {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value
+ .map((item) => normalizeBootDevice(item))
+ .filter((item): item is OnboardingInternalBootDevice => item !== null);
+};
+
const normalizeStepId = (value: unknown): OnboardingStepId | undefined => {
if (typeof value !== 'string') {
return undefined;
@@ -127,7 +159,7 @@ const normalizeInternalBootSelection = (value: unknown): OnboardingInternalBootS
slotCount: Number.isFinite(parsedSlotCount)
? Math.max(1, Math.min(2, parsedSlotCount))
: undefined,
- devices: normalizeStringArray(candidate.devices),
+ devices: normalizeBootDeviceArray(candidate.devices),
bootSizeMiB: Number.isFinite(parsedBootSize) ? Math.max(0, parsedBootSize) : undefined,
updateBios: normalizeBoolean(candidate.updateBios, false),
poolMode: normalizePoolMode(candidate.poolMode),
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 e223c6fae9..0cb31e912f 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
@@ -2,6 +2,7 @@ import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
+import { GraphQLBigInt } from 'graphql-scalars';
import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js';
@@ -433,6 +434,18 @@ export class OnboardingWizardPluginsDraft {
selectedIds!: string[];
}
+@ObjectType()
+export class OnboardingWizardInternalBootDevice {
+ @Field(() => String)
+ id!: string;
+
+ @Field(() => GraphQLBigInt)
+ sizeBytes!: number;
+
+ @Field(() => String)
+ deviceName!: string;
+}
+
@ObjectType()
export class OnboardingWizardInternalBootSelection {
@Field(() => String, { nullable: true })
@@ -441,8 +454,8 @@ export class OnboardingWizardInternalBootSelection {
@Field(() => Int, { nullable: true })
slotCount?: number;
- @Field(() => [String])
- devices!: string[];
+ @Field(() => [OnboardingWizardInternalBootDevice])
+ devices!: OnboardingWizardInternalBootDevice[];
@Field(() => Int, { nullable: true })
bootSizeMiB?: number;
diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
index af135f51a5..ef75ddfeb1 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
@@ -23,6 +23,12 @@ import {
} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js';
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
vi.mock('@app/core/utils/files/file-exists.js');
vi.mock('fs/promises', async () => {
const actual = await vi.importActual('fs/promises');
@@ -1946,7 +1952,10 @@ describe('OnboardingService - updateCfgFile', () => {
selection: {
poolName: 'cache',
slotCount: 2,
- devices: ['disk1', 'disk2'],
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ ],
bootSizeMiB: 32768,
updateBios: true,
poolMode: OnboardingWizardPoolMode.HYBRID,
@@ -1974,7 +1983,10 @@ describe('OnboardingService - updateCfgFile', () => {
selection: {
poolName: 'cache',
slotCount: 2,
- devices: ['disk1', 'disk2'],
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ ],
bootSizeMiB: 32768,
updateBios: true,
poolMode: 'hybrid',
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 cc7a626690..051aeac167 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
@@ -483,7 +483,12 @@ export class OnboardingService implements OnModuleInit {
? {
poolName: draft.internalBoot.selection.poolName,
slotCount: draft.internalBoot.selection.slotCount,
- devices: draft.internalBoot.selection.devices ?? [],
+ devices:
+ draft.internalBoot.selection.devices?.map((device) => ({
+ id: device.id,
+ sizeBytes: device.sizeBytes,
+ deviceName: device.deviceName,
+ })) ?? [],
bootSizeMiB: draft.internalBoot.selection.bootSizeMiB,
updateBios: draft.internalBoot.selection.updateBios,
poolMode: toWizardPoolMode(draft.internalBoot.selection.poolMode),
@@ -606,7 +611,13 @@ export class OnboardingService implements OnModuleInit {
? {
poolName: input.draft.internalBoot.selection.poolName,
slotCount: input.draft.internalBoot.selection.slotCount,
- devices: input.draft.internalBoot.selection.devices,
+ devices: input.draft.internalBoot.selection.devices?.map(
+ (device) => ({
+ id: device.id,
+ sizeBytes: device.sizeBytes,
+ deviceName: device.deviceName,
+ })
+ ),
bootSizeMiB:
input.draft.internalBoot.selection.bootSizeMiB,
updateBios:
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 c80faca2b4..5379e519ea 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
@@ -15,6 +15,7 @@ import {
Min,
ValidateNested,
} from 'class-validator';
+import { GraphQLBigInt } from 'graphql-scalars';
import {
OnboardingWizardBootMode,
@@ -341,6 +342,24 @@ export class OnboardingWizardPluginsDraftInput {
selectedIds?: string[];
}
+@InputType()
+export class OnboardingWizardInternalBootDeviceInput {
+ @Field(() => String)
+ @IsString()
+ @IsNotEmpty()
+ id!: string;
+
+ @Field(() => GraphQLBigInt)
+ @IsInt()
+ @Min(1)
+ sizeBytes!: number;
+
+ @Field(() => String)
+ @IsString()
+ @IsNotEmpty()
+ deviceName!: string;
+}
+
@InputType()
export class OnboardingWizardInternalBootSelectionInput {
@Field(() => String, { nullable: true })
@@ -354,10 +373,11 @@ export class OnboardingWizardInternalBootSelectionInput {
@Min(1)
slotCount?: number;
- @Field(() => [String], { nullable: true })
+ @Field(() => [OnboardingWizardInternalBootDeviceInput], { nullable: true })
@IsOptional()
- @IsString({ each: true })
- devices?: string[];
+ @ValidateNested({ each: true })
+ @Type(() => OnboardingWizardInternalBootDeviceInput)
+ devices?: OnboardingWizardInternalBootDeviceInput[];
@Field(() => Int, { nullable: true })
@IsOptional()
diff --git a/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts b/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts
new file mode 100644
index 0000000000..af7f0ffdb4
--- /dev/null
+++ b/web/__test__/components/Onboarding/OnboardingBootConfigurationSummary.test.ts
@@ -0,0 +1,199 @@
+import { mount } from '@vue/test-utils';
+
+import { describe, expect, it } from 'vitest';
+
+import type { BootConfigurationSummaryLabels } from '~/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel';
+
+import { buildBootConfigurationSummaryViewModel } from '~/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel';
+import OnboardingBootConfigurationSummary from '~/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue';
+
+const labels: BootConfigurationSummaryLabels = {
+ title: 'Boot Configuration',
+ bootMethod: 'Boot Method',
+ bootMethodStorage: 'Storage Drive(s)',
+ bootMethodUsb: 'USB/Flash Drive',
+ poolMode: 'Pool mode',
+ poolModeDedicated: 'Dedicated boot pool',
+ poolModeHybrid: 'Boot + data pool',
+ pool: 'Pool',
+ slots: 'Boot devices',
+ bootReserved: 'Boot Reserved',
+ updateBios: 'Update BIOS',
+ devices: 'Devices',
+ yes: 'Yes',
+ no: 'No',
+};
+
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
+describe('OnboardingBootConfigurationSummary', () => {
+ it('builds a hidden result when boot configuration was skipped', () => {
+ expect(
+ buildBootConfigurationSummaryViewModel(
+ {
+ bootMode: 'usb',
+ skipped: true,
+ selection: null,
+ },
+ {
+ labels,
+ formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`,
+ formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`,
+ }
+ )
+ ).toEqual({ kind: 'hidden' });
+ });
+
+ it('builds a usb summary without storage rows', () => {
+ const result = buildBootConfigurationSummaryViewModel(
+ {
+ bootMode: 'usb',
+ skipped: false,
+ selection: null,
+ },
+ {
+ labels,
+ formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`,
+ formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`,
+ }
+ );
+
+ expect(result).toEqual({
+ kind: 'ready',
+ summary: {
+ title: 'Boot Configuration',
+ rows: [
+ {
+ key: 'bootMethod',
+ label: 'Boot Method',
+ value: 'USB/Flash Drive',
+ },
+ ],
+ devicesLabel: 'Devices',
+ devices: [],
+ },
+ });
+ });
+
+ it('builds a hybrid storage summary and falls back to raw device ids when labels are missing', () => {
+ const result = buildBootConfigurationSummaryViewModel(
+ {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: [
+ createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'),
+ createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'),
+ ],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ {
+ labels,
+ formatBootSize: (bootSizeMiB) => `${bootSizeMiB / 1024} GB`,
+ formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`,
+ }
+ );
+
+ expect(result).toEqual({
+ kind: 'ready',
+ summary: {
+ title: 'Boot Configuration',
+ rows: [
+ {
+ key: 'bootMethod',
+ label: 'Boot Method',
+ value: 'Storage Drive(s)',
+ },
+ {
+ key: 'poolMode',
+ label: 'Pool mode',
+ value: 'Boot + data pool',
+ },
+ {
+ key: 'pool',
+ label: 'Pool',
+ value: 'cache',
+ },
+ {
+ key: 'slots',
+ label: 'Boot devices',
+ value: '2',
+ },
+ {
+ key: 'bootReserved',
+ label: 'Boot Reserved',
+ value: '16 GB',
+ },
+ {
+ key: 'updateBios',
+ label: 'Update BIOS',
+ value: 'Yes',
+ },
+ ],
+ devicesLabel: 'Devices',
+ devices: [
+ { id: 'DISK-A', label: 'DISK-A - 537 GB (sda)' },
+ { id: 'DISK-B', label: 'DISK-B - 268 GB (sdb)' },
+ ],
+ },
+ });
+ });
+
+ it('returns an invalid result for incomplete storage selections', () => {
+ expect(
+ buildBootConfigurationSummaryViewModel(
+ {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: '',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ {
+ labels,
+ formatBootSize: (bootSizeMiB) => `${bootSizeMiB} MiB`,
+ formatDeviceSize: (sizeBytes) => `${Math.round(sizeBytes / 1_000_000_000)} GB`,
+ missingStorageSelectionBehavior: 'invalid',
+ }
+ )
+ ).toEqual({
+ kind: 'invalid',
+ reason: 'INCOMPLETE_STORAGE_SELECTION',
+ });
+ });
+
+ it('renders the shared boot summary card', () => {
+ const wrapper = mount(OnboardingBootConfigurationSummary, {
+ props: {
+ summary: {
+ title: 'Boot Configuration',
+ rows: [
+ { key: 'bootMethod', label: 'Boot Method', value: 'Storage Drive(s)' },
+ { key: 'slots', label: 'Boot devices', value: '2' },
+ ],
+ devicesLabel: 'Devices',
+ devices: [{ id: 'DISK-A', label: 'DISK-A - 537 GB (sda)' }],
+ },
+ },
+ });
+
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('Boot Configuration');
+ expect(wrapper.text()).toContain('Storage Drive(s)');
+ expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)');
+ });
+});
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
index 612b636f0c..ead43017bc 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
@@ -14,6 +14,12 @@ import { createTestI18n } from '../../utils/i18n';
const INTERNAL_BOOT_HISTORY_STATE_KEY = '__unraidOnboardingInternalBoot';
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
type InternalBootHistoryState = {
sessionId: string;
stepId: 'CONFIGURE_BOOT' | 'SUMMARY';
@@ -113,7 +119,7 @@ vi.mock('@/components/Onboarding/steps/OnboardingInternalBootStep.vue', () => ({
? null
: {
...initialDraft.selection,
- devices: [...(initialDraft.selection.devices ?? [])],
+ devices: (initialDraft.selection.devices ?? []).map((device) => ({ ...device })),
},
};
};
@@ -256,6 +262,8 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
const wrapper = mountComponent();
await advanceToSummary(wrapper);
+
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(false);
await confirmAndApply(wrapper);
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
@@ -274,7 +282,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -290,6 +298,11 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
const wrapper = mountComponent();
await advanceToSummary(wrapper);
+
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('Boot Configuration');
+ expect(wrapper.text()).toContain('Storage Drive(s)');
+ expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)');
await confirmAndApply(wrapper);
expect(applyInternalBootSelectionMock).toHaveBeenCalledWith(
@@ -318,6 +331,38 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
});
+ it('shows an editable error state when the standalone boot summary is invalid', async () => {
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: '',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ };
+
+ const wrapper = mountComponent();
+
+ await advanceToSummary(wrapper);
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="internal-boot-summary-invalid"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('Your boot configuration is incomplete.');
+ expect(findButtonByText(wrapper, 'Confirm & Apply')).toBeFalsy();
+
+ await findButtonByText(wrapper, 'Back')!.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
+ expect(stepperPropsRef.value).toMatchObject({
+ activeStepIndex: 0,
+ });
+ });
+
it('shows a locked failure result with reboot controls when apply fails', async () => {
configureDraftState.value = {
bootMode: 'storage',
@@ -325,7 +370,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -384,7 +429,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -456,7 +501,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -481,7 +526,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 2c38ad038d..b46db57a93 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -12,7 +12,7 @@ import { createTestI18n } from '../../utils/i18n';
type MockInternalBootSelection = {
poolName: string;
slotCount: number;
- devices: string[];
+ devices: Array<{ id: string; sizeBytes: number; deviceName: string }>;
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts
index 109a40800e..d2a4dc52ac 100644
--- a/web/__test__/components/Onboarding/OnboardingModal.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts
@@ -138,7 +138,7 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
selection: {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
diff --git a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
index a10793eea1..5374a5e646 100644
--- a/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingNextStepsStep.test.ts
@@ -25,7 +25,7 @@ const {
internalBootSelection: null as {
poolName: string;
slotCount: number;
- devices: string[];
+ devices: Array<{ id: string; sizeBytes: number; deviceName: string }>;
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
@@ -55,6 +55,12 @@ const {
useMutationMock: vi.fn(),
}));
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
vi.mock('@unraid/ui', () => ({
BrandButton: {
props: ['text', 'disabled'],
@@ -191,7 +197,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -255,7 +261,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -271,7 +277,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -287,7 +293,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -303,7 +309,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -334,7 +340,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -354,7 +360,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -389,7 +395,7 @@ describe('OnboardingNextStepsStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 09b6186bdf..ec8923d23c 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -62,7 +62,7 @@ const {
internalBootSelection: null as {
poolName: string;
slotCount: number;
- devices: string[];
+ devices: Array<{ id: string; sizeBytes: number; deviceName: string }>;
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
@@ -113,6 +113,12 @@ const {
useQueryMock: vi.fn(),
}));
+const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => ({
+ id,
+ sizeBytes,
+ deviceName,
+});
+
vi.mock('pinia', async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -1184,6 +1190,7 @@ describe('OnboardingSummaryStep', () => {
expect(wrapper.text()).toContain('Boot Configuration');
expect(wrapper.text()).toContain('USB/Flash Drive');
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true);
});
it('hides boot configuration section when internal boot step was skipped', () => {
@@ -1213,7 +1220,10 @@ describe('OnboardingSummaryStep', () => {
draftStore.internalBootSelection = {
poolName: 'boot',
slotCount: 2,
- devices: ['DISK-A', 'DISK-B'],
+ devices: [
+ createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'),
+ createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'),
+ ],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -1221,16 +1231,38 @@ describe('OnboardingSummaryStep', () => {
const { wrapper } = mountComponent();
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(true);
expect(wrapper.text()).toContain('DISK-A - 537 GB (sda)');
expect(wrapper.text()).toContain('DISK-B - 268 GB (sdb)');
});
+ it('shows an inline warning and blocks apply when boot configuration is incomplete', () => {
+ draftStore.bootMode = 'storage';
+ draftStore.internalBootSelection = {
+ poolName: '',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ };
+
+ const { wrapper } = mountComponent();
+ const buttons = wrapper.findAll('[data-testid="brand-button"]');
+ const applyButton = buttons[buttons.length - 1];
+
+ expect(wrapper.find('[data-testid="boot-configuration-summary"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="boot-configuration-summary-invalid"]').exists()).toBe(true);
+ expect(wrapper.text()).toContain('This boot configuration is incomplete.');
+ expect(applyButton.attributes('disabled')).toBeDefined();
+ });
+
it('requires confirmation before applying storage boot drive changes', async () => {
draftStore.bootMode = 'storage';
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -1256,7 +1288,10 @@ describe('OnboardingSummaryStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 2,
- devices: ['DISK-A', 'DISK-B'],
+ devices: [
+ createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda'),
+ createBootDevice('DISK-B', 250 * 1024 * 1024 * 1024, 'sdb'),
+ ],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
@@ -1310,7 +1345,7 @@ describe('OnboardingSummaryStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
@@ -1341,7 +1376,7 @@ describe('OnboardingSummaryStep', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
- devices: ['DISK-A'],
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
diff --git a/web/components.d.ts b/web/components.d.ts
index 8ba941e38e..824d47c6a0 100644
--- a/web/components.d.ts
+++ b/web/components.d.ts
@@ -97,6 +97,7 @@ declare module 'vue' {
OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default']
OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default']
'OnboardingAdminPanel.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingAdminPanel.standalone.vue')['default']
+ OnboardingBootConfigurationSummary: typeof import('./src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue')['default']
OnboardingConsole: typeof import('./src/components/Onboarding/components/OnboardingConsole.vue')['default']
OnboardingCoreSettingsStep: typeof import('./src/components/Onboarding/steps/OnboardingCoreSettingsStep.vue')['default']
'OnboardingInternalBoot.standalone': typeof import('./src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue')['default']
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index b7ff032d93..ff17d33b88 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -220,7 +220,11 @@ const normalizeWizardDraft = (): OnboardingWizardDraft => ({
: {
poolName: wizard.value.draft.internalBoot.selection.poolName ?? undefined,
slotCount: wizard.value.draft.internalBoot.selection.slotCount ?? undefined,
- devices: [...wizard.value.draft.internalBoot.selection.devices],
+ devices: wizard.value.draft.internalBoot.selection.devices.map((device) => ({
+ id: device.id,
+ sizeBytes: device.sizeBytes,
+ deviceName: device.deviceName,
+ })),
bootSizeMiB: wizard.value.draft.internalBoot.selection.bootSizeMiB ?? undefined,
updateBios: wizard.value.draft.internalBoot.selection.updateBios ?? undefined,
poolMode:
@@ -410,7 +414,11 @@ const buildSaveInput = (nextStepId: StepId) => ({
poolName: localDraft.value.internalBoot.selection.poolName,
slotCount: localDraft.value.internalBoot.selection.slotCount,
devices: localDraft.value.internalBoot.selection.devices
- ? [...localDraft.value.internalBoot.selection.devices]
+ ? localDraft.value.internalBoot.selection.devices.map((device) => ({
+ id: device.id,
+ sizeBytes: device.sizeBytes,
+ deviceName: device.deviceName,
+ }))
: [],
bootSizeMiB: localDraft.value.internalBoot.selection.bootSizeMiB,
updateBios: localDraft.value.internalBoot.selection.updateBios,
@@ -526,7 +534,9 @@ const updateInternalBootDraft = (draft: OnboardingInternalBootDraft) => {
? null
: {
...draft.selection,
- devices: draft.selection.devices ? [...draft.selection.devices] : [],
+ devices: draft.selection.devices
+ ? draft.selection.devices.map((device) => ({ ...device }))
+ : [],
},
},
});
diff --git a/web/src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue b/web/src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue
new file mode 100644
index 0000000000..330dda35e1
--- /dev/null
+++ b/web/src/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+ {{ summary.title }}
+
+
+
+
+
+ {{ row.label }}
+ {{ row.value }}
+
+
+
+
{{ summary.devicesLabel }}
+
+
+ {{ device.label }}
+
+
+
+
+
+
diff --git a/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts b/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
new file mode 100644
index 0000000000..b43590d096
--- /dev/null
+++ b/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
@@ -0,0 +1,222 @@
+import type {
+ OnboardingBootMode,
+ OnboardingInternalBootDevice,
+ OnboardingInternalBootDraft,
+ OnboardingPoolMode,
+} from '@/components/Onboarding/onboardingWizardState';
+
+export interface BootConfigurationSummaryLabels {
+ title: string;
+ bootMethod: string;
+ bootMethodStorage: string;
+ bootMethodUsb: string;
+ poolMode: string;
+ poolModeDedicated: string;
+ poolModeHybrid: string;
+ pool: string;
+ slots: string;
+ bootReserved: string;
+ updateBios: string;
+ devices: string;
+ yes: string;
+ no: string;
+}
+
+export interface BootConfigurationSummaryRow {
+ key: 'bootMethod' | 'poolMode' | 'pool' | 'slots' | 'bootReserved' | 'updateBios';
+ label: string;
+ value: string;
+}
+
+export interface BootConfigurationSummaryDevice {
+ id: string;
+ label: string;
+}
+
+export interface BootConfigurationSummaryViewModel {
+ title: string;
+ rows: BootConfigurationSummaryRow[];
+ devicesLabel: string;
+ devices: BootConfigurationSummaryDevice[];
+}
+
+export type BootConfigurationSummaryBuildResult =
+ | { kind: 'hidden' }
+ | {
+ kind: 'invalid';
+ reason:
+ | 'UNSUPPORTED_BOOT_MODE'
+ | 'UNEXPECTED_USB_SELECTION'
+ | 'MISSING_STORAGE_SELECTION'
+ | 'INCOMPLETE_STORAGE_SELECTION';
+ }
+ | {
+ kind: 'ready';
+ summary: BootConfigurationSummaryViewModel;
+ };
+
+interface BuildBootConfigurationSummaryViewModelOptions {
+ labels: BootConfigurationSummaryLabels;
+ formatBootSize: (bootSizeMiB: number) => string;
+ formatDeviceSize: (sizeBytes: number) => string;
+ missingStorageSelectionBehavior?: 'hidden' | 'invalid';
+}
+
+const isKnownBootMode = (value: unknown): value is OnboardingBootMode =>
+ value === 'usb' || value === 'storage';
+
+const isKnownPoolMode = (value: unknown): value is OnboardingPoolMode =>
+ value === 'dedicated' || value === 'hybrid';
+
+const hasNonEmptyString = (value: unknown): value is string =>
+ typeof value === 'string' && value.trim().length > 0;
+
+const hasPositiveInteger = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isInteger(value) && value > 0;
+
+const hasFiniteNumber = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value);
+
+const isValidBootDevice = (value: unknown): value is OnboardingInternalBootDevice => {
+ if (!value || typeof value !== 'object') {
+ return false;
+ }
+
+ const candidate = value as Record;
+ return (
+ hasNonEmptyString(candidate.id) &&
+ hasFiniteNumber(candidate.sizeBytes) &&
+ candidate.sizeBytes > 0 &&
+ hasNonEmptyString(candidate.deviceName)
+ );
+};
+
+const normalizeDeviceName = (value: string) => {
+ const trimmed = value.trim();
+ if (trimmed.startsWith('/dev/')) {
+ return trimmed.slice('/dev/'.length);
+ }
+ return trimmed;
+};
+
+export const formatBootDeviceLabel = (
+ device: OnboardingInternalBootDevice,
+ formatDeviceSize: (sizeBytes: number) => string
+) => {
+ const normalizedDeviceName = normalizeDeviceName(device.deviceName);
+ const sizeLabel = formatDeviceSize(device.sizeBytes);
+ return normalizedDeviceName === device.id
+ ? `${device.id} - ${sizeLabel}`
+ : `${device.id} - ${sizeLabel} (${normalizedDeviceName})`;
+};
+
+export const buildBootConfigurationSummaryViewModel = (
+ draft: OnboardingInternalBootDraft | null | undefined,
+ options: BuildBootConfigurationSummaryViewModelOptions
+): BootConfigurationSummaryBuildResult => {
+ if (!draft || draft.skipped) {
+ return { kind: 'hidden' };
+ }
+
+ const selection = draft.selection ?? null;
+ const bootMode = draft.bootMode ?? (selection ? 'storage' : undefined);
+ if (!isKnownBootMode(bootMode)) {
+ return { kind: 'hidden' };
+ }
+
+ if (bootMode === 'usb') {
+ if (selection) {
+ return { kind: 'invalid', reason: 'UNEXPECTED_USB_SELECTION' };
+ }
+
+ return {
+ kind: 'ready',
+ summary: {
+ title: options.labels.title,
+ rows: [
+ {
+ key: 'bootMethod',
+ label: options.labels.bootMethod,
+ value: options.labels.bootMethodUsb,
+ },
+ ],
+ devicesLabel: options.labels.devices,
+ devices: [],
+ },
+ };
+ }
+
+ if (!selection) {
+ return options.missingStorageSelectionBehavior === 'invalid'
+ ? { kind: 'invalid', reason: 'MISSING_STORAGE_SELECTION' }
+ : { kind: 'hidden' };
+ }
+
+ const devices = Array.isArray(selection.devices)
+ ? selection.devices.filter((device): device is OnboardingInternalBootDevice =>
+ isValidBootDevice(device)
+ )
+ : [];
+ const poolMode = selection.poolMode;
+
+ if (
+ !isKnownPoolMode(poolMode) ||
+ !hasPositiveInteger(selection.slotCount) ||
+ devices.length === 0 ||
+ !hasFiniteNumber(selection.bootSizeMiB) ||
+ typeof selection.updateBios !== 'boolean' ||
+ (poolMode === 'hybrid' && !hasNonEmptyString(selection.poolName))
+ ) {
+ return { kind: 'invalid', reason: 'INCOMPLETE_STORAGE_SELECTION' };
+ }
+
+ const rows: BootConfigurationSummaryRow[] = [
+ {
+ key: 'bootMethod',
+ label: options.labels.bootMethod,
+ value: options.labels.bootMethodStorage,
+ },
+ {
+ key: 'poolMode',
+ label: options.labels.poolMode,
+ value: poolMode === 'dedicated' ? options.labels.poolModeDedicated : options.labels.poolModeHybrid,
+ },
+ {
+ key: 'slots',
+ label: options.labels.slots,
+ value: String(selection.slotCount),
+ },
+ {
+ key: 'updateBios',
+ label: options.labels.updateBios,
+ value: selection.updateBios ? options.labels.yes : options.labels.no,
+ },
+ ];
+
+ if (poolMode !== 'dedicated') {
+ const poolName = selection.poolName?.trim() ?? '';
+ rows.splice(2, 0, {
+ key: 'pool',
+ label: options.labels.pool,
+ value: poolName,
+ });
+ rows.splice(4, 0, {
+ key: 'bootReserved',
+ label: options.labels.bootReserved,
+ value: options.formatBootSize(selection.bootSizeMiB),
+ });
+ }
+
+ return {
+ kind: 'ready',
+ summary: {
+ title: options.labels.title,
+ rows,
+ devicesLabel: options.labels.devices,
+ devices: devices.map((device) => ({
+ id: device.id,
+ label: formatBootDeviceLabel(device, options.formatDeviceSize),
+ })),
+ },
+ };
+};
diff --git a/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts b/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
index 029dc88b21..380198e54b 100644
--- a/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
+++ b/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
@@ -78,7 +78,11 @@ export const ONBOARDING_BOOTSTRAP_QUERY = graphql(/* GraphQL */ `
selection {
poolName
slotCount
- devices
+ devices {
+ id
+ sizeBytes
+ deviceName
+ }
bootSizeMiB
updateBios
poolMode
diff --git a/web/src/components/Onboarding/onboardingWizardState.ts b/web/src/components/Onboarding/onboardingWizardState.ts
index a1cebd5e5a..eb74e9ac71 100644
--- a/web/src/components/Onboarding/onboardingWizardState.ts
+++ b/web/src/components/Onboarding/onboardingWizardState.ts
@@ -4,10 +4,16 @@ export type OnboardingPoolMode = 'dedicated' | 'hybrid';
export type OnboardingBootMode = 'usb' | 'storage';
+export interface OnboardingInternalBootDevice {
+ id: string;
+ sizeBytes: number;
+ deviceName: string;
+}
+
export interface OnboardingInternalBootSelection {
poolName?: string;
slotCount?: number;
- devices?: string[];
+ devices?: OnboardingInternalBootDevice[];
bootSizeMiB?: number;
updateBios?: boolean;
poolMode?: OnboardingPoolMode;
@@ -75,7 +81,7 @@ export const cloneOnboardingWizardDraft = (draft: OnboardingWizardDraft): Onboar
: {
...draft.internalBoot.selection,
devices: draft.internalBoot.selection.devices
- ? [...draft.internalBoot.selection.devices]
+ ? draft.internalBoot.selection.devices.map((device) => ({ ...device }))
: [],
},
}
diff --git a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
index 3b9ee782d6..1d0e30011d 100644
--- a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
+++ b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
@@ -9,6 +9,8 @@ import {
XMarkIcon,
} from '@heroicons/vue/24/solid';
import { Dialog } from '@unraid/ui';
+import { buildBootConfigurationSummaryViewModel } from '@/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel';
+import OnboardingBootConfigurationSummary from '@/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue';
import OnboardingConsole from '@/components/Onboarding/components/OnboardingConsole.vue';
import {
applyInternalBootSelection,
@@ -19,6 +21,7 @@ import OnboardingSteps from '@/components/Onboarding/OnboardingSteps.vue';
import { createEmptyOnboardingWizardInternalBootState } from '@/components/Onboarding/onboardingWizardState';
import OnboardingInternalBootStep from '@/components/Onboarding/steps/OnboardingInternalBootStep.vue';
import { cleanupOnboardingStorage } from '@/components/Onboarding/store/onboardingStorageCleanup';
+import { convert } from 'convert';
import type { LogEntry } from '@/components/Onboarding/components/OnboardingConsole.vue';
import type {
@@ -60,6 +63,8 @@ const standaloneSteps: Array<{ id: StepId; required: boolean }> = [
const summaryT = (key: string, values?: Record) =>
t(`onboarding.summaryStep.${key}`, values ?? {});
+const standaloneSummaryInvalidMessage =
+ 'Your boot configuration is incomplete. Go back and review the boot settings before applying changes.';
const isLocked = computed(() => internalBootState.value.applyAttempted);
@@ -80,6 +85,37 @@ const canReturnToConfigure = () =>
const showConsole = computed(() => confirmationState.value === 'saving' || logs.value.length > 0);
const isSaving = computed(() => confirmationState.value === 'saving');
const canEditAgain = computed(() => currentStep.value === 'SUMMARY' && canReturnToConfigure());
+const formatDeviceSize = (sizeBytes: number) => {
+ const converted = convert(sizeBytes, 'B').to('best', 'metric');
+ const precision = converted.quantity >= 100 || converted.unit === 'B' ? 0 : 1;
+ return `${converted.quantity.toFixed(precision)} ${converted.unit}`;
+};
+const bootConfigurationSummaryState = computed(() =>
+ buildBootConfigurationSummaryViewModel(internalBootDraft.value, {
+ labels: {
+ title: summaryT('bootConfig.title'),
+ bootMethod: summaryT('bootConfig.bootMethod'),
+ bootMethodStorage: summaryT('bootConfig.bootMethodStorage'),
+ bootMethodUsb: summaryT('bootConfig.bootMethodUsb'),
+ poolMode: summaryT('bootConfig.poolMode'),
+ poolModeDedicated: summaryT('bootConfig.poolModeDedicated'),
+ poolModeHybrid: summaryT('bootConfig.poolModeHybrid'),
+ pool: summaryT('bootConfig.pool'),
+ slots: summaryT('bootConfig.slots'),
+ bootReserved: summaryT('bootConfig.bootReserved'),
+ updateBios: summaryT('bootConfig.updateBios'),
+ devices: summaryT('bootConfig.devices'),
+ yes: summaryT('yes'),
+ no: summaryT('no'),
+ },
+ formatBootSize: (bootSizeMiB) =>
+ bootSizeMiB === 0
+ ? t('onboarding.internalBootStep.bootSize.wholeDrive')
+ : t('onboarding.internalBootStep.bootSize.gbLabel', { size: Math.round(bootSizeMiB / 1024) }),
+ formatDeviceSize,
+ missingStorageSelectionBehavior: 'invalid',
+ })
+);
const currentStepIndex = computed(() =>
standaloneSteps.findIndex((step) => step.id === currentStep.value)
);
@@ -171,7 +207,7 @@ const toInternalBootSelection = (draft: OnboardingInternalBootDraft): InternalBo
return {
poolName: selection.poolName,
slotCount: selection.slotCount,
- devices: [...selection.devices],
+ devices: selection.devices.map((device) => device.id),
bootSizeMiB: selection.bootSizeMiB,
updateBios: selection.updateBios,
poolMode: selection.poolMode,
@@ -242,7 +278,9 @@ const handleConfigureStepComplete = async (draft: OnboardingInternalBootDraft) =
? null
: {
...draft.selection,
- devices: draft.selection.devices ? [...draft.selection.devices] : [],
+ devices: draft.selection.devices
+ ? draft.selection.devices.map((device) => ({ ...device }))
+ : [],
},
};
currentStep.value = 'SUMMARY';
@@ -259,7 +297,9 @@ const handleConfigureStepBack = async (draft: OnboardingInternalBootDraft) => {
? null
: {
...draft.selection,
- devices: draft.selection.devices ? [...draft.selection.devices] : [],
+ devices: draft.selection.devices
+ ? draft.selection.devices.map((device) => ({ ...device }))
+ : [],
},
};
};
@@ -496,23 +536,18 @@ onUnmounted(() => {
-
-
-
{{ summaryT('bootConfig.bootMethod') }}
-
- {{
- internalBootDraft.bootMode === 'storage'
- ? summaryT('bootConfig.bootMethodStorage')
- : summaryT('bootConfig.bootMethodUsb')
- }}
-
-
-
-
{{ summaryT('bootConfig.devices') }}
-
- {{ internalBootDraft.selection.devices?.join(', ') || t('common.none') }}
-
-
+
+
+
+
+
+
+ {{ standaloneSummaryInvalidMessage }}
+
@@ -524,6 +559,7 @@ onUnmounted(() => {
{{ t('common.back') }}
(value === 'storage'
interface InternalBootDeviceOption {
value: string;
label: string;
+ sizeBytes: number;
device: string;
sizeMiB: number | null;
ineligibilityCodes: InternalBootDiskEligibilityCode[];
@@ -193,6 +195,7 @@ const templateData = computed(() => {
return {
value: optionValue,
label: buildDeviceLabel(displayId, sizeLabel, device),
+ sizeBytes,
device,
sizeMiB,
ineligibilityCodes,
@@ -578,6 +581,20 @@ const getDeviceSelectItems = (index: number): SelectMenuItem[] =>
disabled: isDeviceDisabled(option.value, index),
}));
+const selectedDeviceById = computed(() => {
+ const devices = new Map();
+
+ for (const option of deviceOptions.value) {
+ devices.set(option.value, {
+ id: option.value,
+ sizeBytes: option.sizeBytes,
+ deviceName: option.device,
+ });
+ }
+
+ return devices;
+});
+
const handleUpdateBiosChange = (value: boolean | 'indeterminate') => {
updateBios.value = value === true;
};
@@ -632,14 +649,23 @@ const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
formError.value = t('onboarding.internalBootStep.validation.devicePerSlot');
return null;
}
- const devices = rawDevices.filter((d): d is string => !!d);
+ const deviceIds = rawDevices.filter((d): d is string => !!d);
- const uniqueDevices = new Set(devices);
- if (uniqueDevices.size !== devices.length) {
+ const uniqueDevices = new Set(deviceIds);
+ if (uniqueDevices.size !== deviceIds.length) {
formError.value = t('onboarding.internalBootStep.validation.uniqueDevices');
return null;
}
+ const devices = deviceIds
+ .map((deviceId) => selectedDeviceById.value.get(deviceId))
+ .filter((device): device is OnboardingInternalBootDevice => device !== undefined);
+
+ if (devices.length !== deviceIds.length) {
+ formError.value = t('onboarding.internalBootStep.validation.devicePerSlot');
+ return null;
+ }
+
if (isDedicatedMode.value) {
return {
poolName: normalizedPoolName,
@@ -688,6 +714,9 @@ const buildDraftSnapshot = (): OnboardingInternalBootDraft => {
const devices = selectedDevices.value
.slice(0, slotCount.value)
.filter((device): device is string => typeof device === 'string' && device.length > 0);
+ const selectedDraftDevices = devices
+ .map((deviceId) => selectedDeviceById.value.get(deviceId))
+ .filter((device): device is OnboardingInternalBootDevice => device !== undefined);
const currentBootSizeMiB = bootSizeMiB.value ?? undefined;
return {
@@ -696,7 +725,7 @@ const buildDraftSnapshot = (): OnboardingInternalBootDraft => {
selection: {
poolName: trimmedPoolName,
slotCount: slotCount.value,
- devices,
+ devices: selectedDraftDevices,
bootSizeMiB: currentBootSizeMiB,
updateBios: updateBios.value,
poolMode: poolMode.value,
@@ -715,7 +744,7 @@ const initializeForm = (data: InternalBootTemplateData) => {
(poolMode.value === 'dedicated' ? 'boot' : (data.poolNameDefault ?? 'cache'));
slotCount.value = draftSelection?.slotCount ?? defaultSlot;
selectedDevices.value =
- draftSelection?.devices?.slice(0, slotCount.value) ??
+ draftSelection?.devices?.map((device) => device.id).slice(0, slotCount.value) ??
Array.from({ length: slotCount.value }, (): string | undefined => undefined);
normalizeSelectedDevices(slotCount.value);
@@ -750,7 +779,7 @@ watch(
poolMode.value = draft.selection.poolMode ?? 'hybrid';
poolName.value = draft.selection.poolName ?? poolName.value;
slotCount.value = draft.selection.slotCount ?? slotCount.value;
- selectedDevices.value = [...(draft.selection.devices ?? [])];
+ selectedDevices.value = (draft.selection.devices ?? []).map((device) => device.id);
normalizeSelectedDevices(slotCount.value);
updateBios.value = draft.selection.updateBios ?? updateBios.value;
applyBootSizeSelection(draft.selection.bootSizeMiB ?? DEFAULT_BOOT_SIZE_MIB);
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index 8c5614ab98..e6f0b20a60 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -6,7 +6,6 @@ import { useMutation, useQuery } from '@vue/apollo-composable';
import {
ChevronLeftIcon,
- CircleStackIcon,
ClipboardDocumentCheckIcon,
CubeIcon,
GlobeAltIcon,
@@ -23,6 +22,8 @@ import {
ExclamationTriangleIcon,
} from '@heroicons/vue/24/solid';
import { Accordion, BrandButton } from '@unraid/ui';
+import { buildBootConfigurationSummaryViewModel } from '@/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel';
+import OnboardingBootConfigurationSummary from '@/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue';
import OnboardingConsole from '@/components/Onboarding/components/OnboardingConsole.vue';
import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
import {
@@ -52,14 +53,9 @@ import type {
OnboardingWizardDraft,
OnboardingWizardInternalBootState,
} from '@/components/Onboarding/onboardingWizardState';
-import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData';
-import {
- GetInternalBootContextDocument,
- PluginInstallStatus,
- ThemeName,
-} from '~/composables/gql/graphql';
+import { PluginInstallStatus, ThemeName } from '~/composables/gql/graphql';
export interface Props {
draft: OnboardingWizardDraft;
@@ -112,9 +108,6 @@ const { result: installedPluginsResult, refetch: refetchInstalledPlugins } = use
const { result: availableLanguagesResult } = useQuery(GET_AVAILABLE_LANGUAGES_QUERY, null, {
fetchPolicy: 'cache-first',
});
-const { result: internalBootContextResult } = useQuery(GetInternalBootContextDocument, null, {
- fetchPolicy: 'network-only',
-});
const draftPluginsCount = computed(() => draftPlugins.value.length);
@@ -149,29 +142,8 @@ const summaryServerDescription = computed(
() => draftCoreSettings.value.serverDescription || coreSettingsResult.value?.server?.comment || ''
);
-const showBootConfiguration = computed(
- () =>
- !draftInternalBoot.value.skipped &&
- (draftInternalBoot.value.bootMode === 'usb' || Boolean(draftInternalBoot.value.selection))
-);
-const selectedBootMode = computed(
- () => draftInternalBoot.value.bootMode ?? (draftInternalBoot.value.selection ? 'storage' : 'usb')
-);
-const bootModeLabel = computed(() =>
- selectedBootMode.value === 'storage'
- ? t('onboarding.summaryStep.bootConfig.bootMethodStorage')
- : t('onboarding.summaryStep.bootConfig.bootMethodUsb')
-);
-
const hasInternalBootSelection = computed(() => Boolean(internalBootSelection.value));
-const formatBootSize = (bootSizeMiB: number) => {
- if (bootSizeMiB === 0) {
- return t('onboarding.internalBootStep.bootSize.wholeDrive');
- }
- return t('onboarding.internalBootStep.bootSize.gbLabel', { size: Math.round(bootSizeMiB / 1024) });
-};
-
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes <= 0) {
return t('onboarding.internalBootStep.unknownSize');
@@ -182,60 +154,6 @@ const formatBytes = (bytes: number) => {
return `${converted.quantity.toFixed(precision)} ${converted.unit}`;
};
-const normalizeDeviceName = (value: string | null | undefined): string => {
- if (!value) {
- return '';
- }
- const trimmed = value.trim();
- if (trimmed.startsWith('/dev/')) {
- return trimmed.slice('/dev/'.length);
- }
- return trimmed;
-};
-
-const internalBootDeviceLabelById = computed(() => {
- const data: GetInternalBootContextQuery | null | undefined = internalBootContextResult.value;
- const disks = data?.internalBootContext.assignableDisks ?? [];
- const labels = new Map();
-
- for (const disk of disks) {
- const device = normalizeDeviceName(disk.device);
- if (!device) {
- continue;
- }
-
- const serialNum = disk.serialNum?.trim() || '';
- const diskId = disk.id?.trim() || '';
- const optionValue = serialNum || diskId || device;
- const displayId = serialNum || device;
- const sizeBytes = disk.size;
- const sizeLabel = formatBytes(sizeBytes);
- const label =
- displayId === device ? `${displayId} - ${sizeLabel}` : `${displayId} - ${sizeLabel} (${device})`;
-
- labels.set(optionValue, label);
- labels.set(device, label);
- }
-
- return labels;
-});
-
-const internalBootSummary = computed(() => {
- const selection = internalBootSelection.value;
- if (!selection) {
- return null;
- }
-
- return {
- poolMode: selection.poolMode ?? 'hybrid',
- poolName: selection.poolName,
- slotCount: selection.slotCount,
- devices: selection.devices,
- bootReservedSize: formatBootSize(selection.bootSizeMiB ?? 0),
- updateBios: selection.updateBios,
- };
-});
-
const toAppliedInternalBootSelection = (
selection: NonNullable
): InternalBootSelection | null => {
@@ -253,7 +171,7 @@ const toAppliedInternalBootSelection = (
return {
poolName: selection.poolName,
slotCount: selection.slotCount,
- devices: [...selection.devices],
+ devices: selection.devices.map((device) => device.id),
bootSizeMiB: selection.bootSizeMiB,
updateBios: selection.updateBios,
poolMode: selection.poolMode,
@@ -298,12 +216,10 @@ const addErrorLog = (message: string, caughtError: unknown, context: OnboardingE
const showDiagnosticLogsInResultDialog = computed(
() => applyResultSeverity.value !== 'success' && logs.value.length > 0
);
-const selectedBootDeviceNames = computed(() => internalBootSummary.value?.devices ?? []);
const selectedBootDevices = computed(() =>
- selectedBootDeviceNames.value.map((deviceId) => ({
- id: deviceId,
- label: internalBootDeviceLabelById.value.get(deviceId) ?? deviceId,
- }))
+ bootConfigurationSummaryState.value.kind === 'ready'
+ ? bootConfigurationSummaryState.value.summary.devices
+ : []
);
const isInstallTimeoutError = (error: unknown): boolean => {
@@ -538,12 +454,45 @@ const isApplyDataReady = computed(() =>
Boolean(coreSettingsResult.value?.server && coreSettingsResult.value?.vars)
);
const hasBaselineQueryError = computed(() => Boolean(coreSettingsError.value));
+const bootConfigurationSummaryState = computed(() =>
+ buildBootConfigurationSummaryViewModel(draftInternalBoot.value, {
+ labels: {
+ title: t('onboarding.summaryStep.bootConfig.title'),
+ bootMethod: t('onboarding.summaryStep.bootConfig.bootMethod'),
+ bootMethodStorage: t('onboarding.summaryStep.bootConfig.bootMethodStorage'),
+ bootMethodUsb: t('onboarding.summaryStep.bootConfig.bootMethodUsb'),
+ poolMode: t('onboarding.summaryStep.bootConfig.poolMode'),
+ poolModeDedicated: t('onboarding.summaryStep.bootConfig.poolModeDedicated'),
+ poolModeHybrid: t('onboarding.summaryStep.bootConfig.poolModeHybrid'),
+ pool: t('onboarding.summaryStep.bootConfig.pool'),
+ slots: t('onboarding.summaryStep.bootConfig.slots'),
+ bootReserved: t('onboarding.summaryStep.bootConfig.bootReserved'),
+ updateBios: t('onboarding.summaryStep.bootConfig.updateBios'),
+ devices: t('onboarding.summaryStep.bootConfig.devices'),
+ yes: t('onboarding.summaryStep.yes'),
+ no: t('onboarding.summaryStep.no'),
+ },
+ formatBootSize: (bootSizeMiB) =>
+ bootSizeMiB === 0
+ ? t('onboarding.internalBootStep.bootSize.wholeDrive')
+ : t('onboarding.internalBootStep.bootSize.gbLabel', { size: Math.round(bootSizeMiB / 1024) }),
+ formatDeviceSize: formatBytes,
+ missingStorageSelectionBehavior: 'hidden',
+ })
+);
+const hasInvalidBootConfiguration = computed(
+ () => bootConfigurationSummaryState.value.kind === 'invalid'
+);
+const bootConfigurationInvalidMessage =
+ 'This boot configuration is incomplete. Go back to Configure Boot to review it before applying changes.';
const applyReadinessTimedOut = ref(false);
const APPLY_READINESS_TIMEOUT_MS = 10000;
let applyReadinessTimer: ReturnType | null = null;
const canApply = computed(
- () => isApplyDataReady.value || applyReadinessTimedOut.value || hasBaselineQueryError.value
+ () =>
+ !hasInvalidBootConfiguration.value &&
+ (isApplyDataReady.value || applyReadinessTimedOut.value || hasBaselineQueryError.value)
);
const showApplyReadinessWarning = computed(
() => !isApplyDataReady.value && (applyReadinessTimedOut.value || hasBaselineQueryError.value)
@@ -1336,74 +1285,18 @@ const handleBack = () => {
-
-
-
-
- {{ t('onboarding.summaryStep.bootConfig.title') }}
-
-
-
-
- {{ t('onboarding.summaryStep.bootConfig.bootMethod') }}
- {{ bootModeLabel }}
-
+
+
+
-
-
- {{ t('onboarding.summaryStep.bootConfig.poolMode') }}
- {{
- internalBootSummary.poolMode === 'dedicated'
- ? t('onboarding.summaryStep.bootConfig.poolModeDedicated')
- : t('onboarding.summaryStep.bootConfig.poolModeHybrid')
- }}
-
-
- {{ t('onboarding.summaryStep.bootConfig.pool') }}
- {{
- internalBootSummary.poolName
- }}
-
-
- {{ t('onboarding.summaryStep.bootConfig.slots') }}
- {{
- internalBootSummary.slotCount
- }}
-
-
- {{ t('onboarding.summaryStep.bootConfig.bootReserved') }}
- {{
- internalBootSummary.bootReservedSize
- }}
-
-
- {{ t('onboarding.summaryStep.bootConfig.updateBios') }}
- {{
- internalBootSummary.updateBios
- ? t('onboarding.summaryStep.yes')
- : t('onboarding.summaryStep.no')
- }}
-
-
-
{{ t('onboarding.summaryStep.bootConfig.devices') }}
-
-
- {{ device.label }}
-
-
-
-
-
+
+
+ {{ bootConfigurationInvalidMessage }}
+
diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts
index 700c4adf3b..b2be23a142 100644
--- a/web/src/composables/gql/gql.ts
+++ b/web/src/composables/gql/gql.ts
@@ -78,7 +78,7 @@ type Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": typeof types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": typeof types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": typeof types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": typeof types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": typeof types.PluginInstallUpdatesDocument,
@@ -167,7 +167,7 @@ const documents: Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": types.PluginInstallUpdatesDocument,
@@ -465,7 +465,7 @@ export function graphql(source: "\n query InstalledUnraidPlugins {\n install
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"];
+export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts
index e6b085bc5e..e21c5314fd 100644
--- a/web/src/composables/gql/graphql.ts
+++ b/web/src/composables/gql/graphql.ts
@@ -2156,6 +2156,19 @@ export type OnboardingWizardDraftInput = {
plugins?: InputMaybe
;
};
+export type OnboardingWizardInternalBootDevice = {
+ __typename?: 'OnboardingWizardInternalBootDevice';
+ deviceName: Scalars['String']['output'];
+ id: Scalars['String']['output'];
+ sizeBytes: Scalars['BigInt']['output'];
+};
+
+export type OnboardingWizardInternalBootDeviceInput = {
+ deviceName: Scalars['String']['input'];
+ id: Scalars['String']['input'];
+ sizeBytes: Scalars['BigInt']['input'];
+};
+
export type OnboardingWizardInternalBootDraft = {
__typename?: 'OnboardingWizardInternalBootDraft';
bootMode?: Maybe;
@@ -2172,7 +2185,7 @@ export type OnboardingWizardInternalBootDraftInput = {
export type OnboardingWizardInternalBootSelection = {
__typename?: 'OnboardingWizardInternalBootSelection';
bootSizeMiB?: Maybe;
- devices: Array;
+ devices: Array;
poolMode?: Maybe;
poolName?: Maybe;
slotCount?: Maybe;
@@ -2181,7 +2194,7 @@ export type OnboardingWizardInternalBootSelection = {
export type OnboardingWizardInternalBootSelectionInput = {
bootSizeMiB?: InputMaybe;
- devices?: InputMaybe>;
+ devices?: InputMaybe>;
poolMode?: InputMaybe;
poolName?: InputMaybe;
slotCount?: InputMaybe;
@@ -4121,7 +4134,7 @@ export type InstalledUnraidPluginsQuery = { __typename?: 'Query', installedUnrai
export type OnboardingBootstrapQueryVariables = Exact<{ [key: string]: never; }>;
-export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean }, wizard: { __typename?: 'OnboardingWizard', currentStepId?: OnboardingWizardStepId | null, visibleStepIds: Array, draft: { __typename?: 'OnboardingWizardDraft', coreSettings?: { __typename?: 'OnboardingWizardCoreSettingsDraft', serverName?: string | null, serverDescription?: string | null, timeZone?: string | null, theme?: string | null, language?: string | null, useSsh?: boolean | null } | null, plugins?: { __typename?: 'OnboardingWizardPluginsDraft', selectedIds: Array } | null, internalBoot?: { __typename?: 'OnboardingWizardInternalBootDraft', bootMode?: OnboardingWizardBootMode | null, skipped?: boolean | null, selection?: { __typename?: 'OnboardingWizardInternalBootSelection', poolName?: string | null, slotCount?: number | null, devices: Array, bootSizeMiB?: number | null, updateBios?: boolean | null, poolMode?: OnboardingWizardPoolMode | null } | null } | null }, internalBootState: { __typename?: 'OnboardingWizardInternalBootState', applyAttempted: boolean, applySucceeded: boolean } } } } | null };
+export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean }, wizard: { __typename?: 'OnboardingWizard', currentStepId?: OnboardingWizardStepId | null, visibleStepIds: Array, draft: { __typename?: 'OnboardingWizardDraft', coreSettings?: { __typename?: 'OnboardingWizardCoreSettingsDraft', serverName?: string | null, serverDescription?: string | null, timeZone?: string | null, theme?: string | null, language?: string | null, useSsh?: boolean | null } | null, plugins?: { __typename?: 'OnboardingWizardPluginsDraft', selectedIds: Array } | null, internalBoot?: { __typename?: 'OnboardingWizardInternalBootDraft', bootMode?: OnboardingWizardBootMode | null, skipped?: boolean | null, selection?: { __typename?: 'OnboardingWizardInternalBootSelection', poolName?: string | null, slotCount?: number | null, bootSizeMiB?: number | null, updateBios?: boolean | null, poolMode?: OnboardingWizardPoolMode | null, devices: Array<{ __typename?: 'OnboardingWizardInternalBootDevice', id: string, sizeBytes: number, deviceName: string }> } | null } | null }, internalBootState: { __typename?: 'OnboardingWizardInternalBootState', applyAttempted: boolean, applySucceeded: boolean } } } } | null };
export type OpenOnboardingMutationVariables = Exact<{ [key: string]: never; }>;
@@ -4314,7 +4327,7 @@ export const GetInternalBootContextDocument = {"kind":"Document","definitions":[
export const InstallLanguageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallLanguage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installLanguage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstallPluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallPlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installPlugin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstalledUnraidPluginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InstalledUnraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installedUnraidPlugins"}}]}}]} as unknown as DocumentNode;
-export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"wizard"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentStepId"}},{"kind":"Field","name":{"kind":"Name","value":"visibleStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"draft"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"coreSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"serverDescription"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"useSsh"}}]}},{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"selectedIds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBoot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootMode"}},{"kind":"Field","name":{"kind":"Name","value":"skipped"}},{"kind":"Field","name":{"kind":"Name","value":"selection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"poolName"}},{"kind":"Field","name":{"kind":"Name","value":"slotCount"}},{"kind":"Field","name":{"kind":"Name","value":"devices"}},{"kind":"Field","name":{"kind":"Name","value":"bootSizeMiB"}},{"kind":"Field","name":{"kind":"Name","value":"updateBios"}},{"kind":"Field","name":{"kind":"Name","value":"poolMode"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBootState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyAttempted"}},{"kind":"Field","name":{"kind":"Name","value":"applySucceeded"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
+export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"wizard"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentStepId"}},{"kind":"Field","name":{"kind":"Name","value":"visibleStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"draft"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"coreSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"serverDescription"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"useSsh"}}]}},{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"selectedIds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBoot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootMode"}},{"kind":"Field","name":{"kind":"Name","value":"skipped"}},{"kind":"Field","name":{"kind":"Name","value":"selection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"poolName"}},{"kind":"Field","name":{"kind":"Name","value":"slotCount"}},{"kind":"Field","name":{"kind":"Name","value":"devices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"sizeBytes"}},{"kind":"Field","name":{"kind":"Name","value":"deviceName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bootSizeMiB"}},{"kind":"Field","name":{"kind":"Name","value":"updateBios"}},{"kind":"Field","name":{"kind":"Name","value":"poolMode"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBootState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyAttempted"}},{"kind":"Field","name":{"kind":"Name","value":"applySucceeded"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const OpenOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"OpenOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallOperationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PluginInstallOperation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallOperation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PluginInstallUpdates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallUpdates"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"operationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"output"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode;
From 1ae183fae5fdc1ca0d9d6cdd5321d4e5d67f7a6b Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Wed, 1 Apr 2026 10:32:50 -0400
Subject: [PATCH 12/52] fix(onboarding-ui): tighten internal boot summary flow
- Purpose: make the standalone internal boot wizard behave like the onboarding summary flow while the boot apply is running, and make storage boot start in the expected hybrid mode by default.
- Before: the standalone summary kept introducing a separate post-confirm section, reboot and shutdown actions could appear while createInternalBootPool was still in flight, and fresh storage-boot setups defaulted to dedicated mode.
- Why that was a problem: the UI implied the user could power off the system before the internal boot apply finished, the standalone flow felt inconsistent with onboarding, and the default pool mode pushed users into the less common dedicated path.
- What changed: the standalone summary now keeps the existing summary card in place, renders logs underneath it, hides all footer actions until the apply finishes, and only shows reboot or shutdown once the locked result state has been reached.
- How it works: the standalone footer now gates locked actions on confirmationState === 'result', the summary/result content stays inside the same card, the internal boot step initializes poolMode to 'hybrid' for fresh storage drafts, and focused Vitest coverage now asserts both the in-flight action lock and the hybrid default.
---
.../OnboardingInternalBootStandalone.test.ts | 54 ++++++++
.../OnboardingInternalBootStep.test.ts | 31 ++++-
.../OnboardingInternalBoot.standalone.vue | 131 +++++++-----------
.../steps/OnboardingInternalBootStep.vue | 4 +-
4 files changed, 134 insertions(+), 86 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
index ead43017bc..d4da1b3379 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
@@ -20,6 +20,17 @@ const createBootDevice = (id: string, sizeBytes: number, deviceName: string) =>
deviceName,
});
+const createDeferred = () => {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((innerResolve, innerReject) => {
+ resolve = innerResolve;
+ reject = innerReject;
+ });
+
+ return { promise, resolve, reject };
+};
+
type InternalBootHistoryState = {
sessionId: string;
stepId: 'CONFIGURE_BOOT' | 'SUMMARY';
@@ -519,6 +530,49 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(true);
});
+ it('hides all summary actions while internal boot apply is still running', async () => {
+ const deferred = createDeferred();
+ configureDraftState.value = {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ };
+ applyInternalBootSelectionMock.mockReturnValueOnce(deferred.promise);
+
+ const wrapper = mountComponent();
+
+ await advanceToSummary(wrapper);
+ const confirmButton = findButtonByText(wrapper, 'Confirm & Apply');
+ expect(confirmButton).toBeTruthy();
+ await confirmButton!.trigger('click');
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="internal-boot-standalone-close"]').exists()).toBe(false);
+ expect(findButtonByText(wrapper, 'Back')).toBeFalsy();
+ expect(findButtonByText(wrapper, 'Confirm & Apply')).toBeFalsy();
+ expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="internal-boot-standalone-result-close"]').exists()).toBe(false);
+
+ deferred.resolve({
+ applySucceeded: true,
+ hadWarnings: false,
+ hadNonOptimisticFailures: false,
+ logs: [{ message: 'Internal boot pool configured.', type: 'success' }],
+ });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="internal-boot-standalone-reboot"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="internal-boot-standalone-shutdown"]').exists()).toBe(true);
+ });
+
it('calls reboot and shutdown helpers from the locked result actions', async () => {
configureDraftState.value = {
bootMode: 'storage',
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index b46db57a93..0206333cb7 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -484,6 +484,28 @@ describe('OnboardingInternalBootStep', () => {
expect(wrapper.find('[data-testid="brand-button"]').attributes('disabled')).toBeUndefined();
});
+ it('defaults storage boot to hybrid mode', async () => {
+ draftStore.bootMode = 'storage';
+ contextResult.value = buildContext({
+ assignableDisks: [
+ {
+ id: 'ELIGIBLE-1',
+ device: '/dev/sda',
+ size: gib(32),
+ serialNum: 'ELIGIBLE-1',
+ interfaceType: DiskInterfaceType.SATA,
+ },
+ ],
+ });
+
+ const wrapper = mountComponent();
+ await flushPromises();
+
+ const vm = wrapper.vm as unknown as InternalBootVm;
+ expect(vm.poolMode).toBe('hybrid');
+ expect(wrapper.get('input[type="text"]').element).toHaveProperty('value', 'cache');
+ });
+
it('allows 6 GiB devices in dedicated mode but not hybrid mode', async () => {
draftStore.bootMode = 'storage';
contextResult.value = buildContext({
@@ -509,7 +531,14 @@ describe('OnboardingInternalBootStep', () => {
await flushPromises();
const vm = wrapper.vm as unknown as InternalBootVm;
- expect(vm.poolMode).toBe('dedicated');
+ expect(vm.poolMode).toBe('hybrid');
+ expect(vm.getDeviceSelectItems(0)).not.toEqual(
+ expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })])
+ );
+
+ vm.poolMode = 'dedicated';
+ await flushPromises();
+
expect(vm.getDeviceSelectItems(0)).toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })])
);
diff --git a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
index 1d0e30011d..0c991df89a 100644
--- a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
+++ b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
@@ -2,12 +2,7 @@
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
-import {
- ArrowPathIcon,
- CheckCircleIcon,
- ExclamationTriangleIcon,
- XMarkIcon,
-} from '@heroicons/vue/24/solid';
+import { CheckCircleIcon, ExclamationTriangleIcon, XMarkIcon } from '@heroicons/vue/24/solid';
import { Dialog } from '@unraid/ui';
import { buildBootConfigurationSummaryViewModel } from '@/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel';
import OnboardingBootConfigurationSummary from '@/components/Onboarding/components/bootConfigurationSummary/OnboardingBootConfigurationSummary.vue';
@@ -83,7 +78,6 @@ const canReturnToConfigure = () =>
(resultSeverity.value !== 'success' || !internalBootState.value.applySucceeded);
const showConsole = computed(() => confirmationState.value === 'saving' || logs.value.length > 0);
-const isSaving = computed(() => confirmationState.value === 'saving');
const canEditAgain = computed(() => currentStep.value === 'SUMMARY' && canReturnToConfigure());
const formatDeviceSize = (sizeBytes: number) => {
const converted = convert(sizeBytes, 'B').to('best', 'metric');
@@ -502,31 +496,7 @@ onUnmounted(() => {
-
-
-
-
-
-
- {{ summaryT('title') }}
-
-
- {{ summaryT('description') }}
-
-
-
-
-
-
-
-
+
{{ summaryT('title') }}
@@ -550,71 +520,66 @@ onUnmounted(() => {
+
+
+
+
+
+
+
+ {{ resultTitle }}
+
+
+ {{ resultMessage }}
+
+
+
+
+
+
+ {{ t('onboarding.nextSteps.internalBootBiosMissed') }}
+
+
+
+
+
+
{{ t('common.back') }}
{{ t('onboarding.summaryStep.confirmAndApply') }}
-
-
-
-
-
-
-
-
-
-
- {{ resultTitle }}
-
-
- {{ resultMessage }}
-
-
-
-
-
-
- {{ t('onboarding.nextSteps.internalBootBiosMissed') }}
-
-
-
-
-
- {{ t('common.back') }}
-
-
+
{
(
toBootMode(props.initialDraft?.bootMode ?? (props.initialDraft?.selection ? 'storage' : 'usb'))
);
-const poolMode = ref('dedicated');
+const poolMode = ref('hybrid');
const poolName = ref('boot');
const slotCount = ref(1);
const selectedDevices = ref>([undefined]);
@@ -738,7 +738,7 @@ const initializeForm = (data: InternalBootTemplateData) => {
const firstSlot = data.slotOptions[0] ?? 1;
const defaultSlot = Math.max(1, Math.min(2, firstSlot));
- poolMode.value = draftSelection?.poolMode ?? 'dedicated';
+ poolMode.value = draftSelection?.poolMode ?? 'hybrid';
poolName.value =
draftSelection?.poolName ||
(poolMode.value === 'dedicated' ? 'boot' : (data.poolNameDefault ?? 'cache'));
From 6d7bb453be3f2dfb98cc41277a75a3129963783f Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Wed, 1 Apr 2026 10:44:51 -0400
Subject: [PATCH 13/52] docs(onboarding): document core settings precedence
- Purpose: capture the Core Settings source-of-truth rules in one place so the field defaults are understandable without re-reading the component logic.
- Before: the precedence for server name, description, timezone, theme, language, and SSH had been discussed and partially documented during other changes, but the explanation was buried and not easy to find later.
- Why that was a problem: the onboarding bootstrap and live Core Settings query serve different roles, and without an explicit precedence table it was easy to misread when browser values, activation metadata, draft values, or live server values should win.
- What changed: ONBOARDING_WIZARD.md now has a dedicated Core Settings Precedence section that explains when bootstrap versus GET_CORE_SETTINGS_QUERY load, what each source is responsible for, and which source wins for each editable field.
- How it works: the doc now mirrors the current OnboardingCoreSettingsStep behavior, including the fresh-setup versus non-fresh-setup timezone rule, the activation-versus-server identity rule, and the supporting live values used for hostname and current IP displays.
---
.../Onboarding/ONBOARDING_WIZARD.md | 109 ++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
index 6af5dff616..6ac7107296 100644
--- a/web/src/components/Onboarding/ONBOARDING_WIZARD.md
+++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
@@ -86,6 +86,115 @@ The old Pinia draft store was removed:
- `web/src/components/Onboarding/store/onboardingDraft.ts`
+## Core Settings Precedence
+
+The Core Settings step uses two different sources:
+
+- modal bootstrap state from `ONBOARDING_BOOTSTRAP_QUERY`
+- live server values from `GET_CORE_SETTINGS_QUERY`
+
+They do different jobs:
+
+- bootstrap decides whether onboarding opens, which steps are visible, and what saved server-owned draft already exists
+- the Core Settings query provides the live baseline for server identity, timezone, display settings, SSH, hostname/TLD, and current IP
+
+### When The Values Load
+
+1. `ONBOARDING_BOOTSTRAP_QUERY` loads first through `web/src/components/Onboarding/store/onboardingContextData.ts`
+2. `OnboardingModal.vue` hydrates the in-memory wizard draft from `wizard.draft`
+3. when the active step is `CONFIGURE_SETTINGS`, `OnboardingCoreSettingsStep.vue` runs `GET_CORE_SETTINGS_QUERY`
+4. once both the live query and onboarding status are known, the step applies its final precedence rules
+
+That means:
+
+- bootstrap is enough to decide whether the wizard should show
+- bootstrap is not enough to decide the final Core Settings defaults
+- the step still waits for its own live query before it can fully choose between draft values, browser timezone, activation metadata, and current server settings
+
+### Source Priority By Field
+
+#### Server Name
+
+Priority:
+
+- saved draft `coreSettings.serverName`
+- activation metadata on fresh setup when available
+- current API/server name
+- trusted fallback default
+
+Notes:
+
+- the step waits for onboarding status before deciding whether this is a fresh setup
+- on fresh setup, activation metadata wins over the current API/server name
+- after onboarding is already completed, the current API/server name wins over activation metadata
+
+#### Server Description
+
+Priority:
+
+- saved draft `coreSettings.serverDescription`
+- on fresh setup with activation metadata, activation comment if one was provided
+- current API/server comment
+- activation comment as fallback
+- trusted fallback default
+
+Notes:
+
+- on first setup with activation metadata, the description intentionally stays empty unless the activation payload included one
+- after onboarding is already completed, the live server comment wins
+
+#### Time Zone
+
+Priority:
+
+- saved draft `coreSettings.timeZone`
+- for fresh setup with no draft timezone: browser timezone
+- live server timezone
+- trusted fallback default
+
+Why onboarding status matters:
+
+- timezone precedence is different for fresh setup vs non-fresh setup
+- fresh setup prefers browser timezone if there is no draft timezone
+- non-fresh setup prefers the server timezone
+
+Notes:
+
+- the browser timezone is detected client-side
+- if the browser timezone cannot be resolved, the step falls back to the live server timezone
+- if neither is available, it falls back to `UTC`
+
+#### Theme
+
+Priority:
+
+- saved draft `coreSettings.theme`
+- live display theme from `GET_CORE_SETTINGS_QUERY`
+- trusted fallback default
+
+#### Language
+
+Priority:
+
+- saved draft `coreSettings.language`
+- live display locale from `GET_CORE_SETTINGS_QUERY`
+- trusted fallback default
+
+#### SSH Access
+
+Priority:
+
+- saved draft `coreSettings.useSsh`
+- live `vars.useSsh` from `GET_CORE_SETTINGS_QUERY`
+- trusted fallback default
+
+### Supporting Live Values
+
+The Core Settings query also sets a few display-only values that are not part of the draft precedence rules above:
+
+- `vars.localTld` feeds the hostname preview
+- `info.primaryNetwork.ipAddress` feeds the current IP display
+
## Saving Rules
The wizard saves only on step transitions.
From 96f8867632126c7ce531acd6800986e630c55809 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Wed, 1 Apr 2026 10:53:55 -0400
Subject: [PATCH 14/52] fix(onboarding-ui): align boot mode option order
- Purpose: make the internal boot pool mode dropdown match the current product default.
- Before: hybrid mode was selected by default, but the dropdown still listed dedicated first and hybrid second.
- Why that was a problem: the select menu looked inconsistent and implied dedicated was still the primary path even after the default had been switched to hybrid.
- What changed: the dropdown options now render hybrid first and dedicated second.
- How it works: the pool mode item list in OnboardingInternalBootStep now builds the hybrid option before the dedicated option, without changing any underlying saved values or validation rules.
---
.../Onboarding/steps/OnboardingInternalBootStep.vue | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
index b4c433e7f0..b5387c0d0f 100644
--- a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue
@@ -326,14 +326,14 @@ const isStorageBootSelected = computed(() => bootMode.value === 'storage');
const isDedicatedMode = computed(() => poolMode.value === 'dedicated');
const poolModeItems = computed(() => [
- {
- value: 'dedicated',
- label: t('onboarding.internalBootStep.poolMode.dedicated'),
- },
{
value: 'hybrid',
label: t('onboarding.internalBootStep.poolMode.hybrid'),
},
+ {
+ value: 'dedicated',
+ label: t('onboarding.internalBootStep.poolMode.dedicated'),
+ },
]);
const isPrimaryActionDisabled = computed(
From 46bff3671de97708c9389e1b788fbac9b4e646d7 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Wed, 1 Apr 2026 12:48:11 -0400
Subject: [PATCH 15/52] refactor(onboarding): move wizard draft boundary to
JSON
- Purpose: shrink the onboarding server-side migration by removing duplicated GraphQL draft schema surface while keeping the useful wizard shell fields typed.
- Before: the onboarding draft shape was modeled repeatedly in GraphQL input classes, GraphQL output classes, generated schema/codegen output, and web field selections.
- Why that was a problem: every draft tweak created synchronized edits across multiple layers, inflated the PR diff, and made the contract brittle to nullable/optional edge cases.
- What this change accomplishes: only onboarding draft payloads now cross the GraphQL boundary as JSON, while currentStepId, visibleStepIds, navigation, and internalBootState remain explicitly typed.
- How it works: the API now accepts and returns draft JSON directly, the tracker normalizes draft data on write and read, the web modal normalizes unknown JSON into typed local state, and focused regression tests cover the new JSON boundary.
---
api/generated-schema.graphql | 98 +-----------
api/src/unraid-api/cli/generated/graphql.ts | 4 +-
.../config/onboarding-tracker.service.spec.ts | 87 +++++++++++
.../config/onboarding-tracker.service.ts | 32 ++--
.../customization/activation-code.model.ts | 93 +----------
.../customization/onboarding.service.spec.ts | 13 +-
.../customization/onboarding.service.ts | 98 +-----------
.../resolvers/onboarding/onboarding.model.ts | 144 +-----------------
.../Onboarding/OnboardingModal.test.ts | 71 +++++++++
.../Onboarding/onboardingWizardState.test.ts | 63 ++++++++
.../components/Onboarding/OnboardingModal.vue | 115 +-------------
.../graphql/onboardingBootstrap.query.ts | 30 +---
.../Onboarding/onboardingWizardState.ts | 117 ++++++++++++++
web/src/composables/gql/gql.ts | 6 +-
web/src/composables/gql/graphql.ts | 106 +------------
15 files changed, 394 insertions(+), 683 deletions(-)
create mode 100644 web/__test__/components/Onboarding/onboardingWizardState.test.ts
diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql
index cde29c8be8..d342df8342 100644
--- a/api/generated-schema.graphql
+++ b/api/generated-schema.graphql
@@ -1027,58 +1027,6 @@ type OnboardingState {
activationRequired: Boolean!
}
-type OnboardingWizardCoreSettingsDraft {
- serverName: String
- serverDescription: String
- timeZone: String
- theme: String
- language: String
- useSsh: Boolean
-}
-
-type OnboardingWizardPluginsDraft {
- selectedIds: [String!]!
-}
-
-type OnboardingWizardInternalBootDevice {
- id: String!
- sizeBytes: BigInt!
- deviceName: String!
-}
-
-type OnboardingWizardInternalBootSelection {
- poolName: String
- slotCount: Int
- devices: [OnboardingWizardInternalBootDevice!]!
- 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!
@@ -1087,7 +1035,7 @@ type OnboardingWizardInternalBootState {
type OnboardingWizard {
currentStepId: OnboardingWizardStepId
visibleStepIds: [OnboardingWizardStepId!]!
- draft: OnboardingWizardDraft!
+ draft: JSON!
internalBootState: OnboardingWizardInternalBootState!
}
@@ -1606,51 +1554,11 @@ input PartnerInfoOverrideInput {
}
input SaveOnboardingDraftInput {
- draft: OnboardingWizardDraftInput
+ draft: JSON
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 OnboardingWizardInternalBootDeviceInput {
- id: String!
- sizeBytes: BigInt!
- deviceName: String!
-}
-
-input OnboardingWizardInternalBootSelectionInput {
- poolName: String
- slotCount: Int
- devices: [OnboardingWizardInternalBootDeviceInput!]
- bootSizeMiB: Int
- updateBios: Boolean
- poolMode: OnboardingWizardPoolMode
-}
-
input OnboardingWizardNavigationInput {
currentStepId: OnboardingWizardStepId
}
@@ -3764,4 +3672,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 0f13fbb5ee..b520973035 100644
--- a/api/src/unraid-api/cli/generated/graphql.ts
+++ b/api/src/unraid-api/cli/generated/graphql.ts
@@ -2113,7 +2113,7 @@ export enum OnboardingStatus {
export type OnboardingWizard = {
__typename?: 'OnboardingWizard';
currentStepId?: Maybe;
- draft: OnboardingWizardDraft;
+ draft: Scalars['JSON']['output'];
internalBootState: OnboardingWizardInternalBootState;
visibleStepIds: Array;
};
@@ -2765,7 +2765,7 @@ export enum Role {
}
export type SaveOnboardingDraftInput = {
- draft?: InputMaybe;
+ draft?: InputMaybe;
internalBootState?: InputMaybe;
navigation?: InputMaybe;
};
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 870f2cf81c..0da1bd359c 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -502,4 +502,91 @@ describe('OnboardingTrackerService tracker state availability', () => {
},
});
});
+
+ it('drops unknown JSON keys while preserving valid onboarding draft fields', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(
+ tracker.saveDraft({
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ theme: 'black',
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: [createBootDevice('disk1', 500_000_000_000, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ ignoredSelectionField: 'ignore-me',
+ } as unknown as Record,
+ ignoredDraftField: 'ignore-me',
+ } as unknown as Record,
+ ignoredTopLevelField: {
+ nested: true,
+ },
+ } as unknown as Record,
+ })
+ ).resolves.toMatchObject({
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ theme: 'black',
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: [createBootDevice('disk1', 500_000_000_000, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ });
+
+ const writtenState = JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1])) as {
+ draft?: Record;
+ };
+ expect(writtenState.draft?.ignoredTopLevelField).toBeUndefined();
+ expect(
+ (writtenState.draft?.internalBoot as Record | undefined)?.ignoredDraftField
+ ).toBeUndefined();
+ expect(
+ (
+ (writtenState.draft?.internalBoot as Record | undefined)?.selection as
+ | Record
+ | undefined
+ )?.ignoredSelectionField
+ ).toBeUndefined();
+ });
});
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index 972c1d6c8e..2b16475dea 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -543,28 +543,32 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
}
const currentState = currentStateResult.state;
- const nextDraft: OnboardingDraft = {
+ const nextDraft = normalizeDraft({
...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,
+ navigation: normalizeNavigation(
+ input.navigation
+ ? {
+ ...currentState.navigation,
+ ...input.navigation,
+ }
+ : currentState.navigation
+ ),
+ internalBootState: normalizeInternalBootState(
+ input.internalBootState
+ ? {
+ ...currentState.internalBootState,
+ ...input.internalBootState,
+ }
+ : currentState.internalBootState
+ ),
};
await this.writeTrackerState(updatedState);
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 0cb31e912f..e766e2d5e8 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,9 +1,10 @@
-import { Field, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
+import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { Transform, Type } from 'class-transformer';
import { IsBoolean, IsIn, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
-import { GraphQLBigInt } from 'graphql-scalars';
+import { GraphQLJSON } from 'graphql-scalars';
+import type { OnboardingDraft } from '@app/unraid-api/config/onboarding-tracker.model.js';
import { Language } from '@app/unraid-api/graph/resolvers/info/display/display.model.js';
import { RegistrationState } from '@app/unraid-api/graph/resolvers/registration/registration.model.js';
@@ -407,90 +408,6 @@ registerEnumType(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 OnboardingWizardInternalBootDevice {
- @Field(() => String)
- id!: string;
-
- @Field(() => GraphQLBigInt)
- sizeBytes!: number;
-
- @Field(() => String)
- deviceName!: string;
-}
-
-@ObjectType()
-export class OnboardingWizardInternalBootSelection {
- @Field(() => String, { nullable: true })
- poolName?: string;
-
- @Field(() => Int, { nullable: true })
- slotCount?: number;
-
- @Field(() => [OnboardingWizardInternalBootDevice])
- devices!: OnboardingWizardInternalBootDevice[];
-
- @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)
@@ -508,8 +425,8 @@ export class OnboardingWizard {
@Field(() => [OnboardingWizardStepId])
visibleStepIds!: OnboardingWizardStepId[];
- @Field(() => OnboardingWizardDraft)
- draft!: OnboardingWizardDraft;
+ @Field(() => GraphQLJSON)
+ draft!: OnboardingDraft;
@Field(() => OnboardingWizardInternalBootState)
internalBootState!: OnboardingWizardInternalBootState;
diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
index ef75ddfeb1..b28d3744e0 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts
@@ -17,8 +17,6 @@ import { OnboardingTrackerService } from '@app/unraid-api/config/onboarding-trac
import {
ActivationCode,
OnboardingStatus,
- OnboardingWizardBootMode,
- OnboardingWizardPoolMode,
OnboardingWizardStepId,
} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
import { OnboardingService } from '@app/unraid-api/graph/resolvers/customization/onboarding.service.js';
@@ -1933,13 +1931,18 @@ describe('OnboardingService - updateCfgFile', () => {
OnboardingWizardStepId.NEXT_STEPS,
]);
expect(response.wizard.currentStepId).toBe(OnboardingWizardStepId.ADD_PLUGINS);
+ expect(response.wizard.draft).toEqual({
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ });
expect(response.wizard.internalBootState).toEqual({
applyAttempted: true,
applySucceeded: false,
});
});
- it('persists nested wizard draft input in tracker format', async () => {
+ it('persists JSON-backed wizard draft input in tracker format', async () => {
await service.saveOnboardingDraft({
draft: {
coreSettings: {
@@ -1947,7 +1950,7 @@ describe('OnboardingService - updateCfgFile', () => {
timeZone: 'America/New_York',
},
internalBoot: {
- bootMode: OnboardingWizardBootMode.STORAGE,
+ bootMode: 'storage',
skipped: false,
selection: {
poolName: 'cache',
@@ -1958,7 +1961,7 @@ describe('OnboardingService - updateCfgFile', () => {
],
bootSizeMiB: 32768,
updateBios: true,
- poolMode: OnboardingWizardPoolMode.HYBRID,
+ poolMode: 'hybrid',
},
},
},
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 051aeac167..b930310db4 100644
--- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
+++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts
@@ -28,8 +28,6 @@ import {
OnboardingState,
OnboardingStatus,
OnboardingWizard,
- OnboardingWizardBootMode,
- OnboardingWizardPoolMode,
OnboardingWizardStepId,
PublicPartnerInfo,
} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
@@ -52,34 +50,6 @@ const WIZARD_STEP_ORDER: OnboardingWizardStepId[] = [
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 {
private readonly logger = new Logger(OnboardingService.name);
@@ -468,35 +438,7 @@ export class OnboardingService implements OnModuleInit {
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?.map((device) => ({
- id: device.id,
- sizeBytes: device.sizeBytes,
- deviceName: device.deviceName,
- })) ?? [],
- bootSizeMiB: draft.internalBoot.selection.bootSizeMiB,
- updateBios: draft.internalBoot.selection.updateBios,
- poolMode: toWizardPoolMode(draft.internalBoot.selection.poolMode),
- }
- : (draft.internalBoot.selection ?? null),
- }
- : undefined,
- },
+ draft,
internalBootState: {
applyAttempted: internalBootState.applyAttempted ?? false,
applySucceeded: internalBootState.applySucceeded ?? false,
@@ -592,43 +534,7 @@ export class OnboardingService implements OnModuleInit {
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?.map(
- (device) => ({
- id: device.id,
- sizeBytes: device.sizeBytes,
- deviceName: device.deviceName,
- })
- ),
- bootSizeMiB:
- input.draft.internalBoot.selection.bootSizeMiB,
- updateBios:
- input.draft.internalBoot.selection.updateBios,
- poolMode: input.draft.internalBoot.selection.poolMode,
- }
- : null,
- }
- : undefined,
- }
- : undefined,
+ draft: input.draft,
navigation: input.navigation
? {
currentStepId: input.navigation.currentStepId,
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 5379e519ea..34845c6aed 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
@@ -15,8 +15,9 @@ import {
Min,
ValidateNested,
} from 'class-validator';
-import { GraphQLBigInt } from 'graphql-scalars';
+import { GraphQLJSON } from 'graphql-scalars';
+import type { OnboardingDraft } from '@app/unraid-api/config/onboarding-tracker.model.js';
import {
OnboardingWizardBootMode,
OnboardingWizardPoolMode,
@@ -301,141 +302,6 @@ 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 OnboardingWizardInternalBootDeviceInput {
- @Field(() => String)
- @IsString()
- @IsNotEmpty()
- id!: string;
-
- @Field(() => GraphQLBigInt)
- @IsInt()
- @Min(1)
- sizeBytes!: number;
-
- @Field(() => String)
- @IsString()
- @IsNotEmpty()
- deviceName!: string;
-}
-
-@InputType()
-export class OnboardingWizardInternalBootSelectionInput {
- @Field(() => String, { nullable: true })
- @IsOptional()
- @IsString()
- poolName?: string;
-
- @Field(() => Int, { nullable: true })
- @IsOptional()
- @IsInt()
- @Min(1)
- slotCount?: number;
-
- @Field(() => [OnboardingWizardInternalBootDeviceInput], { nullable: true })
- @IsOptional()
- @ValidateNested({ each: true })
- @Type(() => OnboardingWizardInternalBootDeviceInput)
- devices?: OnboardingWizardInternalBootDeviceInput[];
-
- @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 })
@@ -459,11 +325,9 @@ export class OnboardingWizardInternalBootStateInput {
@InputType()
export class SaveOnboardingDraftInput {
- @Field(() => OnboardingWizardDraftInput, { nullable: true })
+ @Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
- @ValidateNested()
- @Type(() => OnboardingWizardDraftInput)
- draft?: OnboardingWizardDraftInput;
+ draft?: OnboardingDraft;
@Field(() => OnboardingWizardNavigationInput, { nullable: true })
@IsOptional()
diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts
index d2a4dc52ac..20317a88a1 100644
--- a/web/__test__/components/Onboarding/OnboardingModal.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts
@@ -310,6 +310,77 @@ describe('OnboardingModal.vue', () => {
);
});
+ it('normalizes JSON bootstrap draft data before re-saving step transitions', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'],
+ draft: {
+ coreSettings: {
+ serverName: ' Existing Tower ',
+ useSsh: 'nope',
+ },
+ plugins: {
+ selectedIds: ['community.applications', 42],
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: '2',
+ devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }, null],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ } as unknown as OnboardingWizardDraft,
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+
+ const wrapper = mountComponent();
+ await flushPromises();
+
+ await wrapper.get('[data-testid="settings-step-complete"]').trigger('click');
+ await flushPromises();
+
+ expect(saveOnboardingDraftMock).toHaveBeenCalledWith({
+ input: {
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ useSsh: true,
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ navigation: {
+ currentStepId: 'ADD_PLUGINS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ },
+ });
+ });
+
it('falls forward to the nearest visible step when the saved step is no longer visible', async () => {
wizardRef.value = {
currentStepId: 'CONFIGURE_BOOT',
diff --git a/web/__test__/components/Onboarding/onboardingWizardState.test.ts b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
new file mode 100644
index 0000000000..7f10ed8c35
--- /dev/null
+++ b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from 'vitest';
+
+import { normalizeOnboardingWizardDraft } from '~/components/Onboarding/onboardingWizardState';
+
+describe('normalizeOnboardingWizardDraft', () => {
+ it('returns a safe empty draft for non-object values', () => {
+ expect(normalizeOnboardingWizardDraft(null)).toEqual({});
+ expect(normalizeOnboardingWizardDraft('bad input')).toEqual({});
+ });
+
+ it('normalizes valid fields and drops malformed nested values', () => {
+ expect(
+ normalizeOnboardingWizardDraft({
+ coreSettings: {
+ serverName: 'Tower',
+ useSsh: 'yes',
+ },
+ plugins: {
+ selectedIds: ['community.applications', 42],
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: '2',
+ devices: [
+ { id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' },
+ { id: '', sizeBytes: 0, deviceName: '' },
+ ],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ })
+ ).toEqual({
+ coreSettings: {
+ serverName: 'Tower',
+ serverDescription: undefined,
+ timeZone: undefined,
+ theme: undefined,
+ language: undefined,
+ useSsh: undefined,
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 2,
+ devices: [{ id: 'DISK-A', sizeBytes: 500 * 1024 * 1024 * 1024, deviceName: 'sda' }],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ });
+ });
+});
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index ff17d33b88..33e5ae82ea 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -26,6 +26,7 @@ import {
cloneOnboardingWizardDraft,
createEmptyOnboardingWizardDraft,
createEmptyOnboardingWizardInternalBootState,
+ normalizeOnboardingWizardDraft,
} from '~/components/Onboarding/onboardingWizardState';
import { STEP_IDS, stepComponents } from '~/components/Onboarding/stepRegistry.js';
import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData';
@@ -33,11 +34,7 @@ import { useOnboardingContextDataStore } from '~/components/Onboarding/store/onb
import { useOnboardingModalStore } from '~/components/Onboarding/store/onboardingModalVisibility';
import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus';
import { cleanupOnboardingStorage } from '~/components/Onboarding/store/onboardingStorageCleanup';
-import {
- OnboardingWizardBootMode,
- OnboardingWizardPoolMode,
- OnboardingWizardStepId,
-} from '~/composables/gql/graphql';
+import { OnboardingWizardStepId } from '~/composables/gql/graphql';
import { usePurchaseStore } from '~/store/purchase';
import { useServerStore } from '~/store/server';
import { useThemeStore } from '~/store/theme';
@@ -187,62 +184,11 @@ const clearHistorySession = () => {
isApplyingHistoryState.value = false;
};
-const normalizeWizardDraft = (): OnboardingWizardDraft => ({
- coreSettings: wizard.value?.draft?.coreSettings
- ? {
- serverName: wizard.value.draft.coreSettings.serverName ?? undefined,
- serverDescription: wizard.value.draft.coreSettings.serverDescription ?? undefined,
- timeZone: wizard.value.draft.coreSettings.timeZone ?? undefined,
- theme: wizard.value.draft.coreSettings.theme ?? undefined,
- language: wizard.value.draft.coreSettings.language ?? undefined,
- useSsh: wizard.value.draft.coreSettings.useSsh ?? undefined,
- }
- : undefined,
- plugins: wizard.value?.draft?.plugins
- ? {
- selectedIds: [...wizard.value.draft.plugins.selectedIds],
- }
- : undefined,
- internalBoot: wizard.value?.draft?.internalBoot
- ? {
- bootMode:
- wizard.value.draft.internalBoot.bootMode === OnboardingWizardBootMode.STORAGE
- ? 'storage'
- : wizard.value.draft.internalBoot.bootMode === OnboardingWizardBootMode.USB
- ? 'usb'
- : undefined,
- skipped: wizard.value.draft.internalBoot.skipped ?? undefined,
- selection:
- wizard.value.draft.internalBoot.selection === undefined
- ? undefined
- : wizard.value.draft.internalBoot.selection === null
- ? null
- : {
- poolName: wizard.value.draft.internalBoot.selection.poolName ?? undefined,
- slotCount: wizard.value.draft.internalBoot.selection.slotCount ?? undefined,
- devices: wizard.value.draft.internalBoot.selection.devices.map((device) => ({
- id: device.id,
- sizeBytes: device.sizeBytes,
- deviceName: device.deviceName,
- })),
- bootSizeMiB: wizard.value.draft.internalBoot.selection.bootSizeMiB ?? undefined,
- updateBios: wizard.value.draft.internalBoot.selection.updateBios ?? undefined,
- poolMode:
- wizard.value.draft.internalBoot.selection.poolMode ===
- OnboardingWizardPoolMode.DEDICATED
- ? 'dedicated'
- : wizard.value.draft.internalBoot.selection.poolMode ===
- OnboardingWizardPoolMode.HYBRID
- ? 'hybrid'
- : undefined,
- },
- }
- : undefined,
-});
-
const hydrateLocalWizardState = () => {
localDraft.value = cloneOnboardingWizardDraft(
- wizard.value?.draft ? normalizeWizardDraft() : createEmptyOnboardingWizardDraft()
+ wizard.value?.draft
+ ? normalizeOnboardingWizardDraft(wizard.value.draft)
+ : createEmptyOnboardingWizardDraft()
);
localCurrentStepId.value = normalizeStepId(wizard.value?.currentStepId) ?? null;
localInternalBootState.value = {
@@ -381,56 +327,7 @@ const toWizardStepId = (stepId: StepId): OnboardingWizardStepId => {
const buildSaveInput = (nextStepId: StepId) => ({
input: {
- draft: {
- coreSettings: localDraft.value.coreSettings
- ? {
- ...localDraft.value.coreSettings,
- }
- : undefined,
- plugins: localDraft.value.plugins
- ? {
- selectedIds: localDraft.value.plugins.selectedIds
- ? [...localDraft.value.plugins.selectedIds]
- : [],
- }
- : undefined,
- internalBoot:
- localDraft.value.internalBoot === undefined
- ? undefined
- : {
- bootMode:
- localDraft.value.internalBoot.bootMode === 'storage'
- ? OnboardingWizardBootMode.STORAGE
- : localDraft.value.internalBoot.bootMode === 'usb'
- ? OnboardingWizardBootMode.USB
- : undefined,
- skipped: localDraft.value.internalBoot.skipped,
- selection:
- localDraft.value.internalBoot.selection === undefined
- ? undefined
- : localDraft.value.internalBoot.selection === null
- ? null
- : {
- poolName: localDraft.value.internalBoot.selection.poolName,
- slotCount: localDraft.value.internalBoot.selection.slotCount,
- devices: localDraft.value.internalBoot.selection.devices
- ? localDraft.value.internalBoot.selection.devices.map((device) => ({
- id: device.id,
- sizeBytes: device.sizeBytes,
- deviceName: device.deviceName,
- }))
- : [],
- bootSizeMiB: localDraft.value.internalBoot.selection.bootSizeMiB,
- updateBios: localDraft.value.internalBoot.selection.updateBios,
- poolMode:
- localDraft.value.internalBoot.selection.poolMode === 'dedicated'
- ? OnboardingWizardPoolMode.DEDICATED
- : localDraft.value.internalBoot.selection.poolMode === 'hybrid'
- ? OnboardingWizardPoolMode.HYBRID
- : undefined,
- },
- },
- },
+ draft: cloneOnboardingWizardDraft(localDraft.value),
navigation: {
currentStepId: toWizardStepId(nextStepId),
},
diff --git a/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts b/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
index 380198e54b..64dd1dc7cf 100644
--- a/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
+++ b/web/src/components/Onboarding/graphql/onboardingBootstrap.query.ts
@@ -60,35 +60,7 @@ export const ONBOARDING_BOOTSTRAP_QUERY = graphql(/* GraphQL */ `
wizard {
currentStepId
visibleStepIds
- draft {
- coreSettings {
- serverName
- serverDescription
- timeZone
- theme
- language
- useSsh
- }
- plugins {
- selectedIds
- }
- internalBoot {
- bootMode
- skipped
- selection {
- poolName
- slotCount
- devices {
- id
- sizeBytes
- deviceName
- }
- bootSizeMiB
- updateBios
- poolMode
- }
- }
- }
+ draft
internalBootState {
applyAttempted
applySucceeded
diff --git a/web/src/components/Onboarding/onboardingWizardState.ts b/web/src/components/Onboarding/onboardingWizardState.ts
index eb74e9ac71..1f3d5138a3 100644
--- a/web/src/components/Onboarding/onboardingWizardState.ts
+++ b/web/src/components/Onboarding/onboardingWizardState.ts
@@ -63,6 +63,123 @@ export const createEmptyOnboardingWizardInternalBootState = (): OnboardingWizard
applySucceeded: false,
});
+const normalizeString = (value: unknown): string | undefined => {
+ if (typeof value !== 'string') {
+ return undefined;
+ }
+
+ const normalized = value.trim();
+ return normalized.length > 0 ? normalized : '';
+};
+
+const normalizeBoolean = (value: unknown): boolean | undefined =>
+ typeof value === 'boolean' ? value : undefined;
+
+const normalizeStringArray = (value: unknown): string[] =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
+
+const normalizePoolMode = (value: unknown): OnboardingPoolMode | undefined =>
+ value === 'dedicated' || value === 'hybrid' ? value : undefined;
+
+const normalizeBootMode = (value: unknown): OnboardingBootMode | undefined =>
+ value === 'usb' || value === 'storage' ? value : undefined;
+
+const normalizeInternalBootDevice = (value: unknown): OnboardingInternalBootDevice | null => {
+ if (!value || typeof value !== 'object') {
+ return null;
+ }
+
+ const candidate = value as Record;
+ const id = normalizeString(candidate.id);
+ const deviceName = normalizeString(candidate.deviceName);
+ const parsedSizeBytes = Number(candidate.sizeBytes);
+
+ if (!id || !deviceName || !Number.isFinite(parsedSizeBytes) || parsedSizeBytes <= 0) {
+ return null;
+ }
+
+ return {
+ id,
+ sizeBytes: parsedSizeBytes,
+ deviceName,
+ };
+};
+
+const normalizeInternalBootDevices = (value: unknown): OnboardingInternalBootDevice[] =>
+ Array.isArray(value)
+ ? value
+ .map((device) => normalizeInternalBootDevice(device))
+ .filter((device): device is OnboardingInternalBootDevice => device !== null)
+ : [];
+
+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 parsedBootSizeMiB = Number(candidate.bootSizeMiB);
+
+ return {
+ poolName: normalizeString(candidate.poolName),
+ slotCount: Number.isFinite(parsedSlotCount) ? parsedSlotCount : undefined,
+ devices: normalizeInternalBootDevices(candidate.devices),
+ bootSizeMiB: Number.isFinite(parsedBootSizeMiB) ? parsedBootSizeMiB : undefined,
+ updateBios: normalizeBoolean(candidate.updateBios),
+ poolMode: normalizePoolMode(candidate.poolMode),
+ };
+};
+
+export const normalizeOnboardingWizardDraft = (value: unknown): OnboardingWizardDraft => {
+ if (!value || typeof value !== 'object') {
+ return {};
+ }
+
+ const candidate = value as Record;
+
+ return {
+ coreSettings:
+ candidate.coreSettings && typeof candidate.coreSettings === 'object'
+ ? {
+ serverName: normalizeString((candidate.coreSettings as Record).serverName),
+ serverDescription: normalizeString(
+ (candidate.coreSettings as Record).serverDescription
+ ),
+ timeZone: normalizeString((candidate.coreSettings as Record).timeZone),
+ theme: normalizeString((candidate.coreSettings as Record).theme),
+ language: normalizeString((candidate.coreSettings as Record).language),
+ useSsh: normalizeBoolean((candidate.coreSettings as Record).useSsh),
+ }
+ : undefined,
+ plugins:
+ candidate.plugins && typeof candidate.plugins === 'object'
+ ? {
+ selectedIds: normalizeStringArray(
+ (candidate.plugins as Record).selectedIds
+ ),
+ }
+ : undefined,
+ internalBoot:
+ candidate.internalBoot && typeof candidate.internalBoot === 'object'
+ ? {
+ bootMode: normalizeBootMode((candidate.internalBoot as Record).bootMode),
+ skipped: normalizeBoolean((candidate.internalBoot as Record).skipped),
+ selection:
+ (candidate.internalBoot as Record).selection === undefined
+ ? undefined
+ : normalizeInternalBootSelection(
+ (candidate.internalBoot as Record).selection
+ ),
+ }
+ : undefined,
+ };
+};
+
export const cloneOnboardingWizardDraft = (draft: OnboardingWizardDraft): OnboardingWizardDraft => ({
coreSettings: draft.coreSettings ? { ...draft.coreSettings } : undefined,
plugins: draft.plugins
diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts
index b2be23a142..f6163e28a9 100644
--- a/web/src/composables/gql/gql.ts
+++ b/web/src/composables/gql/gql.ts
@@ -78,7 +78,7 @@ type Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": typeof types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": typeof types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": typeof types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": typeof types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": typeof types.PluginInstallUpdatesDocument,
@@ -167,7 +167,7 @@ const documents: Documents = {
"\n mutation InstallLanguage($input: InstallPluginInput!) {\n unraidPlugins {\n installLanguage(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallLanguageDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallPluginDocument,
"\n query InstalledUnraidPlugins {\n installedUnraidPlugins\n }\n": types.InstalledUnraidPluginsDocument,
- "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": types.OnboardingBootstrapDocument,
+ "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n": types.OnboardingBootstrapDocument,
"\n mutation OpenOnboarding {\n onboarding {\n openOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.OpenOnboardingDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": types.PluginInstallUpdatesDocument,
@@ -465,7 +465,7 @@ export function graphql(source: "\n query InstalledUnraidPlugins {\n install
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
-export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft {\n coreSettings {\n serverName\n serverDescription\n timeZone\n theme\n language\n useSsh\n }\n plugins {\n selectedIds\n }\n internalBoot {\n bootMode\n skipped\n selection {\n poolName\n slotCount\n devices {\n id\n sizeBytes\n deviceName\n }\n bootSizeMiB\n updateBios\n poolMode\n }\n }\n }\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"];
+export function graphql(source: "\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query OnboardingBootstrap {\n customization {\n activationCode {\n code\n partner {\n name\n url\n hardwareSpecsUrl\n manualUrl\n supportUrl\n extraLinks {\n title\n url\n }\n }\n branding {\n header\n headermetacolor\n background\n showBannerGradient\n theme\n bannerImage\n caseModel\n caseModelImage\n partnerLogoLightUrl\n partnerLogoDarkUrl\n hasPartnerLogo\n onboardingTitle\n onboardingSubtitle\n onboardingTitleFreshInstall\n onboardingSubtitleFreshInstall\n onboardingTitleUpgrade\n onboardingSubtitleUpgrade\n onboardingTitleDowngrade\n onboardingSubtitleDowngrade\n onboardingTitleIncomplete\n onboardingSubtitleIncomplete\n }\n system {\n serverName\n model\n }\n }\n onboarding {\n status\n isPartnerBuild\n completed\n completedAtVersion\n shouldOpen\n onboardingState {\n registrationState\n isRegistered\n isFreshInstall\n hasActivationCode\n activationRequired\n }\n wizard {\n currentStepId\n visibleStepIds\n draft\n internalBootState {\n applyAttempted\n applySucceeded\n }\n }\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts
index e21c5314fd..d755a424c8 100644
--- a/web/src/composables/gql/graphql.ts
+++ b/web/src/composables/gql/graphql.ts
@@ -2113,94 +2113,11 @@ export enum OnboardingStatus {
export type OnboardingWizard = {
__typename?: 'OnboardingWizard';
currentStepId?: Maybe;
- draft: OnboardingWizardDraft;
+ draft: Scalars['JSON']['output'];
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 OnboardingWizardInternalBootDevice = {
- __typename?: 'OnboardingWizardInternalBootDevice';
- deviceName: Scalars['String']['output'];
- id: Scalars['String']['output'];
- sizeBytes: Scalars['BigInt']['output'];
-};
-
-export type OnboardingWizardInternalBootDeviceInput = {
- deviceName: Scalars['String']['input'];
- id: Scalars['String']['input'];
- sizeBytes: Scalars['BigInt']['input'];
-};
-
-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'];
@@ -2216,21 +2133,6 @@ 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',
@@ -2765,7 +2667,7 @@ export enum Role {
}
export type SaveOnboardingDraftInput = {
- draft?: InputMaybe;
+ draft?: InputMaybe;
internalBootState?: InputMaybe;
navigation?: InputMaybe;
};
@@ -4134,7 +4036,7 @@ export type InstalledUnraidPluginsQuery = { __typename?: 'Query', installedUnrai
export type OnboardingBootstrapQueryVariables = Exact<{ [key: string]: never; }>;
-export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean }, wizard: { __typename?: 'OnboardingWizard', currentStepId?: OnboardingWizardStepId | null, visibleStepIds: Array, draft: { __typename?: 'OnboardingWizardDraft', coreSettings?: { __typename?: 'OnboardingWizardCoreSettingsDraft', serverName?: string | null, serverDescription?: string | null, timeZone?: string | null, theme?: string | null, language?: string | null, useSsh?: boolean | null } | null, plugins?: { __typename?: 'OnboardingWizardPluginsDraft', selectedIds: Array } | null, internalBoot?: { __typename?: 'OnboardingWizardInternalBootDraft', bootMode?: OnboardingWizardBootMode | null, skipped?: boolean | null, selection?: { __typename?: 'OnboardingWizardInternalBootSelection', poolName?: string | null, slotCount?: number | null, bootSizeMiB?: number | null, updateBios?: boolean | null, poolMode?: OnboardingWizardPoolMode | null, devices: Array<{ __typename?: 'OnboardingWizardInternalBootDevice', id: string, sizeBytes: number, deviceName: string }> } | null } | null }, internalBootState: { __typename?: 'OnboardingWizardInternalBootState', applyAttempted: boolean, applySucceeded: boolean } } } } | null };
+export type OnboardingBootstrapQuery = { __typename?: 'Query', customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partner?: { __typename?: 'PartnerConfig', name?: string | null, url?: string | null, hardwareSpecsUrl?: string | null, manualUrl?: string | null, supportUrl?: string | null, extraLinks?: Array<{ __typename?: 'PartnerLink', title: string, url: string }> | null } | null, branding?: { __typename?: 'BrandingConfig', header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null, bannerImage?: string | null, caseModel?: string | null, caseModelImage?: string | null, partnerLogoLightUrl?: string | null, partnerLogoDarkUrl?: string | null, hasPartnerLogo?: boolean | null, onboardingTitle?: string | null, onboardingSubtitle?: string | null, onboardingTitleFreshInstall?: string | null, onboardingSubtitleFreshInstall?: string | null, onboardingTitleUpgrade?: string | null, onboardingSubtitleUpgrade?: string | null, onboardingTitleDowngrade?: string | null, onboardingSubtitleDowngrade?: string | null, onboardingTitleIncomplete?: string | null, onboardingSubtitleIncomplete?: string | null } | null, system?: { __typename?: 'SystemConfig', serverName?: string | null, model?: string | null } | null } | null, onboarding: { __typename?: 'Onboarding', status: OnboardingStatus, isPartnerBuild: boolean, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean, onboardingState: { __typename?: 'OnboardingState', registrationState?: RegistrationState | null, isRegistered: boolean, isFreshInstall: boolean, hasActivationCode: boolean, activationRequired: boolean }, wizard: { __typename?: 'OnboardingWizard', currentStepId?: OnboardingWizardStepId | null, visibleStepIds: Array, draft: any, internalBootState: { __typename?: 'OnboardingWizardInternalBootState', applyAttempted: boolean, applySucceeded: boolean } } } } | null };
export type OpenOnboardingMutationVariables = Exact<{ [key: string]: never; }>;
@@ -4327,7 +4229,7 @@ export const GetInternalBootContextDocument = {"kind":"Document","definitions":[
export const InstallLanguageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallLanguage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installLanguage"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstallPluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallPlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installPlugin"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode;
export const InstalledUnraidPluginsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"InstalledUnraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installedUnraidPlugins"}}]}}]} as unknown as DocumentNode;
-export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"wizard"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentStepId"}},{"kind":"Field","name":{"kind":"Name","value":"visibleStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"draft"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"coreSettings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"serverDescription"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"useSsh"}}]}},{"kind":"Field","name":{"kind":"Name","value":"plugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"selectedIds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBoot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootMode"}},{"kind":"Field","name":{"kind":"Name","value":"skipped"}},{"kind":"Field","name":{"kind":"Name","value":"selection"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"poolName"}},{"kind":"Field","name":{"kind":"Name","value":"slotCount"}},{"kind":"Field","name":{"kind":"Name","value":"devices"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"sizeBytes"}},{"kind":"Field","name":{"kind":"Name","value":"deviceName"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bootSizeMiB"}},{"kind":"Field","name":{"kind":"Name","value":"updateBios"}},{"kind":"Field","name":{"kind":"Name","value":"poolMode"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"internalBootState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyAttempted"}},{"kind":"Field","name":{"kind":"Name","value":"applySucceeded"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
+export const OnboardingBootstrapDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"OnboardingBootstrap"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partner"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"hardwareSpecsUrl"}},{"kind":"Field","name":{"kind":"Name","value":"manualUrl"}},{"kind":"Field","name":{"kind":"Name","value":"supportUrl"}},{"kind":"Field","name":{"kind":"Name","value":"extraLinks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"branding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}},{"kind":"Field","name":{"kind":"Name","value":"bannerImage"}},{"kind":"Field","name":{"kind":"Name","value":"caseModel"}},{"kind":"Field","name":{"kind":"Name","value":"caseModelImage"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoLightUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoDarkUrl"}},{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitle"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleUpgrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleDowngrade"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingTitleIncomplete"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingSubtitleIncomplete"}}]}},{"kind":"Field","name":{"kind":"Name","value":"system"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"model"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"isPartnerBuild"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}},{"kind":"Field","name":{"kind":"Name","value":"onboardingState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"registrationState"}},{"kind":"Field","name":{"kind":"Name","value":"isRegistered"}},{"kind":"Field","name":{"kind":"Name","value":"isFreshInstall"}},{"kind":"Field","name":{"kind":"Name","value":"hasActivationCode"}},{"kind":"Field","name":{"kind":"Name","value":"activationRequired"}}]}},{"kind":"Field","name":{"kind":"Name","value":"wizard"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentStepId"}},{"kind":"Field","name":{"kind":"Name","value":"visibleStepIds"}},{"kind":"Field","name":{"kind":"Name","value":"draft"}},{"kind":"Field","name":{"kind":"Name","value":"internalBootState"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"applyAttempted"}},{"kind":"Field","name":{"kind":"Name","value":"applySucceeded"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode;
export const OpenOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"OpenOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"openOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallOperationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PluginInstallOperation"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallOperation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]} as unknown as DocumentNode;
export const PluginInstallUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PluginInstallUpdates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallUpdates"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"operationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"output"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode;
From 90d59b1338eb9f530a1eec626a7975364bf481ec Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Wed, 1 Apr 2026 13:53:55 -0400
Subject: [PATCH 16/52] test(onboarding): update tracker state expectations
- Purpose: fix the API CI failure caused by stale onboarding tracker expectations after the server-owned wizard state expansion.
- Before: api-config tracker tests still expected only completed, completedAtVersion, and forceOpen in the public tracker result.
- Why that was a problem: the tracker public state now also includes draft, navigation, and internalBootState, so the old deep-equality assertions failed in CI.
- What this change accomplishes: the API config tests now assert the full current tracker contract, including the default empty wizard state fields.
- How it works: the three onboarding tracker expectations in api-config.test.ts now include empty draft and navigation objects plus the default internal boot apply flags.
---
api/src/unraid-api/config/api-config.test.ts | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts
index 5862b7f880..cb7c3e2117 100644
--- a/api/src/unraid-api/config/api-config.test.ts
+++ b/api/src/unraid-api/config/api-config.test.ts
@@ -169,6 +169,12 @@ describe('OnboardingTracker', () => {
completed: false,
completedAtVersion: undefined,
forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
},
});
});
@@ -196,6 +202,12 @@ describe('OnboardingTracker', () => {
completed: true,
completedAtVersion: '7.1.0',
forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
},
});
});
@@ -288,6 +300,12 @@ describe('OnboardingTracker', () => {
completed: true,
completedAtVersion: '6.12.0',
forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
},
});
});
From a0bf3b11b1edc0eaf229812c8880d19de18d6bb8 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 12:34:38 -0400
Subject: [PATCH 17/52] chore(codegen): refresh api graphql types
- Purpose: bring the generated API GraphQL types in sync with the onboarding draft JSON boundary changes already on the branch.
- Before: the committed generated file still included the removed nested onboarding draft GraphQL types and enums.
- Why that was a problem: the generated CLI GraphQL types no longer matched the current schema and resolver models on this branch.
- What this change accomplishes: the generated API GraphQL output now reflects the simplified onboarding draft contract.
- How it works: the stale onboarding draft input/output types and enums are removed from the generated graphql.ts file to match the current schema.
---
api/src/unraid-api/cli/generated/graphql.ts | 98 ---------------------
1 file changed, 98 deletions(-)
diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts
index b520973035..f820e219ce 100644
--- a/api/src/unraid-api/cli/generated/graphql.ts
+++ b/api/src/unraid-api/cli/generated/graphql.ts
@@ -2118,89 +2118,6 @@ export type OnboardingWizard = {
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 OnboardingWizardInternalBootDevice = {
- __typename?: 'OnboardingWizardInternalBootDevice';
- deviceName: Scalars['String']['output'];
- id: Scalars['String']['output'];
- sizeBytes: Scalars['BigInt']['output'];
-};
-
-export type OnboardingWizardInternalBootDeviceInput = {
- deviceName: Scalars['String']['input'];
- id: Scalars['String']['input'];
- sizeBytes: Scalars['BigInt']['input'];
-};
-
-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'];
@@ -2216,21 +2133,6 @@ 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',
From a8d4ca52f5c58fac5e503231b8c491ed69fa932a Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 15:10:59 -0400
Subject: [PATCH 18/52] fix(onboarding-tracker): persist drafts from base
tracker state
- Purpose: keep onboarding draft and wizard-state writes grounded in the persisted tracker file even when testing overrides are active.
- Before: saveDraft() and part of clearWizardState() operated on the override-composed public view, so temporary override fields like forceOpen/completed could leak into onboarding-tracker.json or be treated differently from other mutators.
- Why that was a problem: override mode is meant to be an in-memory testing layer, not a way to silently rewrite the real persisted completion metadata when a user advances through the wizard.
- What this change accomplishes: draft saves and wizard clears now mutate only the persisted base tracker state while still returning the override-composed public view to callers.
- How it works: add an internal persisted-state read path, route saveDraft() and clearWizardState() through it, and extend tracker specs to assert that override-backed reads no longer change the on-disk completion/forceOpen fields.
---
.../config/onboarding-tracker.service.spec.ts | 155 ++++++++++++++++++
.../config/onboarding-tracker.service.ts | 35 ++--
2 files changed, 167 insertions(+), 23 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 0da1bd359c..a6208cc753 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -139,6 +139,13 @@ describe('OnboardingTrackerService write retries', () => {
forceOpen: true,
...createEmptyWizardState(),
});
+ expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: true,
+ ...createEmptyWizardState(),
+ });
});
});
@@ -421,6 +428,154 @@ describe('OnboardingTrackerService tracker state availability', () => {
expect(writtenState.draft?.internalBoot?.selection?.poolName).toBe('cache');
});
+ it('saves drafts against persisted tracker state when overrides are active', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ overrides.setState({
+ onboarding: {
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ },
+ });
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: false,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ },
+ navigation: {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(
+ tracker.saveDraft({
+ draft: {
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ })
+ ).resolves.toEqual({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ navigation: {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+
+ expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: false,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ plugins: {
+ selectedIds: ['community.applications'],
+ },
+ },
+ navigation: {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+
+ it('clears wizard state on disk while leaving override-backed onboarding state temporary', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ overrides.setState({
+ onboarding: {
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ },
+ });
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: false,
+ draft: {
+ coreSettings: {
+ serverName: 'Tower',
+ },
+ },
+ navigation: {
+ currentStepId: 'SUMMARY',
+ },
+ internalBootState: {
+ applyAttempted: true,
+ applySucceeded: true,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(tracker.clearWizardState()).resolves.toEqual({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: true,
+ ...createEmptyWizardState(),
+ });
+
+ expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(String(mockAtomicWriteFile.mock.calls[0]?.[1]))).toMatchObject({
+ completed: true,
+ completedAtVersion: '7.1.0',
+ forceOpen: false,
+ ...createEmptyWizardState(),
+ });
+ });
+
it('persists internal boot status updates while preserving existing draft state', async () => {
const config = createConfigService();
const overrides = new OnboardingOverrideService();
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index 2b16475dea..803e9ca823 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -364,12 +364,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
};
}
- const trackerStateResult = await this.readTrackerStateResult();
- if (trackerStateResult.kind !== 'error') {
- this.state = trackerStateResult.state;
- }
-
- return trackerStateResult;
+ return this.getPersistedStateResult();
}
/**
@@ -497,22 +492,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
}
async clearWizardState(): Promise {
- const overrideState = this.onboardingOverrides.getState();
- if (overrideState?.onboarding !== undefined) {
- this.state = {
- ...this.state,
- draft: {},
- navigation: {},
- internalBootState: {
- applyAttempted: false,
- applySucceeded: false,
- },
- };
-
- return this.getCachedState();
- }
-
- const currentStateResult = await this.getStateResult();
+ const currentStateResult = await this.getPersistedStateResult();
if (currentStateResult.kind === 'error') {
throw currentStateResult.error;
}
@@ -537,7 +517,7 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
}
async saveDraft(input: SaveOnboardingDraftInput): Promise {
- const currentStateResult = await this.getStateResult();
+ const currentStateResult = await this.getPersistedStateResult();
if (currentStateResult.kind === 'error') {
throw currentStateResult.error;
}
@@ -577,6 +557,15 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
return this.getCachedState();
}
+ private async getPersistedStateResult(): Promise {
+ const trackerStateResult = await this.readTrackerStateResult();
+ if (trackerStateResult.kind !== 'error') {
+ this.state = trackerStateResult.state;
+ }
+
+ return trackerStateResult;
+ }
+
private async readCurrentVersion(): Promise {
try {
const contents = await readFile(this.versionFilePath, 'utf8');
From e54f953b5f8f01791b4997022ba43a4290885662 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 15:31:33 -0400
Subject: [PATCH 19/52] fix(onboarding-tracker): preserve base fields in
overrides
- Purpose: keep partial onboarding overrides additive instead of resetting unspecified completion fields to default values.
- Before: a partial override like forceOpen-only would silently force completed=false and completedAtVersion=undefined in the public tracker view.
- Why that was a problem: override mode is used for testing and debugging, and unspecified override fields should inherit the persisted tracker state rather than changing effective completion state.
- What this change accomplishes: partial overrides now preserve the base tracker completion metadata while still overriding the fields they explicitly set.
- How it works: merge override onboarding fields against baseState instead of hardcoded false/undefined defaults, and add a regression spec that proves a forceOpen-only override keeps the persisted completed/completedAtVersion values.
---
.../config/onboarding-tracker.service.spec.ts | 60 ++++++++++++++++++-
.../config/onboarding-tracker.service.ts | 7 ++-
2 files changed, 61 insertions(+), 6 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 a6208cc753..7f8e9f4bdf 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -233,6 +233,43 @@ describe('OnboardingTrackerService tracker state availability', () => {
});
});
+ it('preserves persisted completion fields when a partial override only forces onboarding open', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ overrides.setState({
+ onboarding: {
+ forceOpen: true,
+ },
+ });
+
+ 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',
+ forceOpen: false,
+ ...createEmptyWizardState(),
+ });
+ });
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(tracker.getStateResult()).resolves.toEqual({
+ kind: 'ok',
+ state: {
+ completed: true,
+ completedAtVersion: '7.2.0',
+ forceOpen: true,
+ ...createEmptyWizardState(),
+ },
+ });
+ });
+
it('clears forceOpen when marking override-backed onboarding completed', async () => {
const config = createConfigService();
const overrides = new OnboardingOverrideService();
@@ -478,15 +515,21 @@ describe('OnboardingTrackerService tracker state availability', () => {
})
).resolves.toEqual({
completed: false,
- completedAtVersion: undefined,
+ completedAtVersion: '7.1.0',
forceOpen: true,
draft: {
coreSettings: {
serverName: 'Tower',
+ serverDescription: undefined,
+ timeZone: undefined,
+ theme: undefined,
+ language: undefined,
+ useSsh: undefined,
},
plugins: {
selectedIds: ['community.applications'],
},
+ internalBoot: undefined,
},
navigation: {
currentStepId: 'CONFIGURE_SETTINGS',
@@ -562,9 +605,20 @@ describe('OnboardingTrackerService tracker state availability', () => {
await expect(tracker.clearWizardState()).resolves.toEqual({
completed: false,
- completedAtVersion: undefined,
+ completedAtVersion: '7.1.0',
forceOpen: true,
- ...createEmptyWizardState(),
+ draft: {
+ coreSettings: undefined,
+ plugins: undefined,
+ internalBoot: undefined,
+ },
+ navigation: {
+ currentStepId: undefined,
+ },
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
});
expect(mockAtomicWriteFile).toHaveBeenCalledTimes(1);
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index 803e9ca823..72d45e6934 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -311,9 +311,10 @@ export class OnboardingTrackerService implements OnApplicationBootstrap {
if (overrideState?.onboarding !== undefined) {
return {
...baseState,
- completed: overrideState.onboarding.completed ?? false,
- completedAtVersion: overrideState.onboarding.completedAtVersion ?? undefined,
- forceOpen: overrideState.onboarding.forceOpen ?? false,
+ completed: overrideState.onboarding.completed ?? baseState.completed,
+ completedAtVersion:
+ overrideState.onboarding.completedAtVersion ?? baseState.completedAtVersion,
+ forceOpen: overrideState.onboarding.forceOpen ?? baseState.forceOpen,
};
}
From b710ef753fb1a3095adf45d6315ce982c2f8bbfc Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 15:45:45 -0400
Subject: [PATCH 20/52] fix(onboarding-tracker): clamp devices to slot count
- Purpose: keep persisted internal-boot selections internally consistent after tracker normalization.
- Before: slotCount was clamped to the supported 1-2 disk range, but devices could still keep more entries than the effective slotCount.
- Why that was a problem: malformed tracker data could normalize into contradictory state like slotCount=2 with 3 devices, forcing downstream code to guess which field was authoritative.
- What this change accomplishes: normalized internal-boot selections now truncate devices to the effective clamped slot count.
- How it works: compute the normalized slotCount first, slice the normalized device array to that size, and add a regression spec covering oversized persisted selections.
---
.../config/onboarding-tracker.service.spec.ts | 61 +++++++++++++++++++
.../config/onboarding-tracker.service.ts | 9 +--
2 files changed, 66 insertions(+), 4 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 7f8e9f4bdf..89ed49669f 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.spec.ts
@@ -798,4 +798,65 @@ describe('OnboardingTrackerService tracker state availability', () => {
)?.ignoredSelectionField
).toBeUndefined();
});
+
+ it('truncates persisted internal-boot devices to the clamped slot count', async () => {
+ const config = createConfigService();
+ const overrides = new OnboardingOverrideService();
+
+ mockReadFile.mockImplementation(async (filePath) => {
+ if (String(filePath).includes('unraid-version')) {
+ return 'version="7.2.0"\n';
+ }
+
+ return JSON.stringify({
+ completed: false,
+ completedAtVersion: undefined,
+ forceOpen: false,
+ draft: {},
+ navigation: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ });
+ });
+ mockAtomicWriteFile.mockResolvedValue(undefined as never);
+
+ const tracker = new OnboardingTrackerService(config, overrides);
+ await tracker.onApplicationBootstrap();
+
+ await expect(
+ tracker.saveDraft({
+ draft: {
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ poolName: 'cache',
+ slotCount: 99,
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ createBootDevice('disk3', 125_000_000_000, 'sdc'),
+ ],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ },
+ },
+ })
+ ).resolves.toMatchObject({
+ draft: {
+ internalBoot: {
+ selection: {
+ slotCount: 2,
+ devices: [
+ createBootDevice('disk1', 500_000_000_000, 'sda'),
+ createBootDevice('disk2', 250_000_000_000, 'sdb'),
+ ],
+ },
+ },
+ },
+ });
+ });
});
diff --git a/api/src/unraid-api/config/onboarding-tracker.service.ts b/api/src/unraid-api/config/onboarding-tracker.service.ts
index 72d45e6934..85522e6d99 100644
--- a/api/src/unraid-api/config/onboarding-tracker.service.ts
+++ b/api/src/unraid-api/config/onboarding-tracker.service.ts
@@ -153,13 +153,14 @@ const normalizeInternalBootSelection = (value: unknown): OnboardingInternalBootS
const candidate = value as Record;
const parsedSlotCount = Number(candidate.slotCount);
const parsedBootSize = Number(candidate.bootSizeMiB);
+ const slotCount = Number.isFinite(parsedSlotCount)
+ ? Math.max(1, Math.min(2, parsedSlotCount))
+ : undefined;
return {
poolName: normalizeString(candidate.poolName),
- slotCount: Number.isFinite(parsedSlotCount)
- ? Math.max(1, Math.min(2, parsedSlotCount))
- : undefined,
- devices: normalizeBootDeviceArray(candidate.devices),
+ slotCount,
+ devices: normalizeBootDeviceArray(candidate.devices).slice(0, slotCount ?? 2),
bootSizeMiB: Number.isFinite(parsedBootSize) ? Math.max(0, parsedBootSize) : undefined,
updateBios: normalizeBoolean(candidate.updateBios, false),
poolMode: normalizePoolMode(candidate.poolMode),
From ea097fb8fc99f68f0c06faf670eb003a034880c0 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 16:03:54 -0400
Subject: [PATCH 21/52] fix(onboarding-web): preserve sparse draft semantics
- Purpose: preserve the difference between untouched draft fields and explicit empty selections after moving the wizard draft to a JSON boundary.
- Before: web normalization and cloning eagerly converted omitted arrays and malformed optional state into [] or null.
- Why that was a problem: untouched sections could look explicitly cleared, plugin defaults could be reapplied after a user chose none, and later saves could manufacture state the user never set.
- What this change accomplishes: optional arrays now stay undefined unless they were actually provided, while explicit empty selections remain intentional.
- How it works: tighten draft normalization and cloning, update plugin selection hydration to treat undefined as default-only and [] as explicit none, and add focused regression coverage.
---
.../Onboarding/OnboardingPluginsStep.test.ts | 25 +++++++-
.../Onboarding/onboardingWizardState.test.ts | 62 ++++++++++++++++++-
.../components/Onboarding/OnboardingModal.vue | 9 +--
.../Onboarding/onboardingWizardState.ts | 24 ++++---
.../steps/OnboardingPluginsStep.vue | 7 ++-
5 files changed, 107 insertions(+), 20 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
index f1f131b6db..c536553d3a 100644
--- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
@@ -69,9 +69,12 @@ describe('OnboardingPluginsStep', () => {
onComplete: vi.fn(),
onBack: vi.fn(),
onSkip: vi.fn(),
- initialDraft: {
- selectedIds: Array.from(draftStore.selectedPlugins),
- },
+ initialDraft:
+ draftStore.selectedPlugins.size > 0
+ ? {
+ selectedIds: Array.from(draftStore.selectedPlugins),
+ }
+ : undefined,
showBack: true,
showSkip: true,
...overrides,
@@ -222,4 +225,20 @@ describe('OnboardingPluginsStep', () => {
});
expect(props.onComplete).not.toHaveBeenCalled();
});
+
+ it('preserves an explicit empty plugin selection instead of restoring defaults', async () => {
+ const { wrapper } = mountComponent({
+ initialDraft: {
+ selectedIds: [],
+ },
+ });
+
+ await flushPromises();
+
+ const switches = wrapper.findAll('[role="switch"]');
+ expect(switches.length).toBe(3);
+ expect(switches[0].attributes('data-state')).toBe('unchecked');
+ expect(switches[1].attributes('data-state')).toBe('unchecked');
+ expect(switches[2].attributes('data-state')).toBe('unchecked');
+ });
});
diff --git a/web/__test__/components/Onboarding/onboardingWizardState.test.ts b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
index 7f10ed8c35..6b7b483ccc 100644
--- a/web/__test__/components/Onboarding/onboardingWizardState.test.ts
+++ b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
-import { normalizeOnboardingWizardDraft } from '~/components/Onboarding/onboardingWizardState';
+import {
+ cloneOnboardingWizardDraft,
+ normalizeOnboardingWizardDraft,
+} from '~/components/Onboarding/onboardingWizardState';
describe('normalizeOnboardingWizardDraft', () => {
it('returns a safe empty draft for non-object values', () => {
@@ -60,4 +63,61 @@ describe('normalizeOnboardingWizardDraft', () => {
},
});
});
+
+ it('preserves omitted optional arrays and malformed selection input as undefined', () => {
+ expect(
+ normalizeOnboardingWizardDraft({
+ plugins: {},
+ internalBoot: {
+ bootMode: 'storage',
+ selection: 'bad-selection',
+ },
+ })
+ ).toEqual({
+ coreSettings: undefined,
+ plugins: {
+ selectedIds: undefined,
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: undefined,
+ selection: undefined,
+ },
+ });
+ });
+});
+
+describe('cloneOnboardingWizardDraft', () => {
+ it('preserves undefined optional arrays instead of manufacturing empty ones', () => {
+ expect(
+ cloneOnboardingWizardDraft({
+ plugins: {
+ selectedIds: undefined,
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ selection: {
+ devices: undefined,
+ },
+ },
+ })
+ ).toEqual({
+ coreSettings: undefined,
+ plugins: {
+ selectedIds: undefined,
+ },
+ internalBoot: {
+ bootMode: 'storage',
+ skipped: undefined,
+ selection: {
+ poolName: undefined,
+ slotCount: undefined,
+ devices: undefined,
+ bootSizeMiB: undefined,
+ updateBios: undefined,
+ poolMode: undefined,
+ },
+ },
+ });
+ });
});
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index 33e5ae82ea..b3b0d6f085 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -413,7 +413,7 @@ const updatePluginsDraft = (draft: OnboardingPluginsDraft) => {
localDraft.value = cloneOnboardingWizardDraft({
...localDraft.value,
plugins: {
- selectedIds: draft.selectedIds ? [...draft.selectedIds] : [],
+ selectedIds: draft.selectedIds === undefined ? undefined : [...draft.selectedIds],
},
});
};
@@ -431,9 +431,10 @@ const updateInternalBootDraft = (draft: OnboardingInternalBootDraft) => {
? null
: {
...draft.selection,
- devices: draft.selection.devices
- ? draft.selection.devices.map((device) => ({ ...device }))
- : [],
+ devices:
+ draft.selection.devices === undefined
+ ? undefined
+ : draft.selection.devices.map((device) => ({ ...device })),
},
},
});
diff --git a/web/src/components/Onboarding/onboardingWizardState.ts b/web/src/components/Onboarding/onboardingWizardState.ts
index 1f3d5138a3..ca1c671eee 100644
--- a/web/src/components/Onboarding/onboardingWizardState.ts
+++ b/web/src/components/Onboarding/onboardingWizardState.ts
@@ -75,8 +75,8 @@ const normalizeString = (value: unknown): string | undefined => {
const normalizeBoolean = (value: unknown): boolean | undefined =>
typeof value === 'boolean' ? value : undefined;
-const normalizeStringArray = (value: unknown): string[] =>
- Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
+const normalizeStringArray = (value: unknown): string[] | undefined =>
+ Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : undefined;
const normalizePoolMode = (value: unknown): OnboardingPoolMode | undefined =>
value === 'dedicated' || value === 'hybrid' ? value : undefined;
@@ -105,20 +105,22 @@ const normalizeInternalBootDevice = (value: unknown): OnboardingInternalBootDevi
};
};
-const normalizeInternalBootDevices = (value: unknown): OnboardingInternalBootDevice[] =>
+const normalizeInternalBootDevices = (value: unknown): OnboardingInternalBootDevice[] | undefined =>
Array.isArray(value)
? value
.map((device) => normalizeInternalBootDevice(device))
.filter((device): device is OnboardingInternalBootDevice => device !== null)
- : [];
+ : undefined;
-const normalizeInternalBootSelection = (value: unknown): OnboardingInternalBootSelection | null => {
+const normalizeInternalBootSelection = (
+ value: unknown
+): OnboardingInternalBootSelection | null | undefined => {
if (value === null) {
return null;
}
if (!value || typeof value !== 'object') {
- return null;
+ return undefined;
}
const candidate = value as Record;
@@ -184,7 +186,8 @@ export const cloneOnboardingWizardDraft = (draft: OnboardingWizardDraft): Onboar
coreSettings: draft.coreSettings ? { ...draft.coreSettings } : undefined,
plugins: draft.plugins
? {
- selectedIds: draft.plugins.selectedIds ? [...draft.plugins.selectedIds] : [],
+ selectedIds:
+ draft.plugins.selectedIds === undefined ? undefined : [...draft.plugins.selectedIds],
}
: undefined,
internalBoot: draft.internalBoot
@@ -197,9 +200,10 @@ export const cloneOnboardingWizardDraft = (draft: OnboardingWizardDraft): Onboar
? null
: {
...draft.internalBoot.selection,
- devices: draft.internalBoot.selection.devices
- ? draft.internalBoot.selection.devices.map((device) => ({ ...device }))
- : [],
+ devices:
+ draft.internalBoot.selection.devices === undefined
+ ? undefined
+ : draft.internalBoot.selection.devices.map((device) => ({ ...device })),
},
}
: undefined,
diff --git a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
index 01f3d4f958..65a6c6a8fb 100644
--- a/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingPluginsStep.vue
@@ -71,8 +71,11 @@ const getPluginInstallDetectionFileNames = (plugin: { id: string; url: string })
const defaultSelectedPluginIds = new Set(['community-apps']);
const buildSelectedPluginsFromDraft = (draft?: OnboardingPluginsDraft | null) => {
- const selectedIds = draft?.selectedIds ?? [];
- return selectedIds.length > 0 ? new Set(selectedIds) : new Set(defaultSelectedPluginIds);
+ if (!draft || draft.selectedIds === undefined) {
+ return new Set(defaultSelectedPluginIds);
+ }
+
+ return new Set(draft.selectedIds);
};
const selectedPlugins = ref>(buildSelectedPluginsFromDraft(props.initialDraft));
From 595c8a0445984b7842ed8fd2bd640b9318335e7b Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 16:07:24 -0400
Subject: [PATCH 22/52] fix(onboarding-web): tighten numeric draft
normalization
- Purpose: keep the JSON-backed wizard draft boundary aligned with the actual internal boot domain constraints.
- Before: the web draft normalizer accepted any finite numbers for and , including fractional, negative, and unsupported values.
- Why that was a problem: malformed persisted draft data could survive into local wizard state and get round-tripped back through the UI.
- What this change accomplishes: now only keeps supported integer values, while keeps only non-negative integers and still allows .
- How it works: replace the generic finite-number check with field-specific normalizers and add regression coverage for invalid counts and zero-sized boot selections.
---
.../Onboarding/onboardingWizardState.test.ts | 54 +++++++++++++++++++
.../Onboarding/onboardingWizardState.ts | 16 ++++--
2 files changed, 66 insertions(+), 4 deletions(-)
diff --git a/web/__test__/components/Onboarding/onboardingWizardState.test.ts b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
index 6b7b483ccc..52a9cbea8b 100644
--- a/web/__test__/components/Onboarding/onboardingWizardState.test.ts
+++ b/web/__test__/components/Onboarding/onboardingWizardState.test.ts
@@ -120,4 +120,58 @@ describe('cloneOnboardingWizardDraft', () => {
},
});
});
+
+ it('only keeps valid integer slot counts and allows zero boot size', () => {
+ expect(
+ normalizeOnboardingWizardDraft({
+ internalBoot: {
+ selection: {
+ slotCount: 1.5,
+ bootSizeMiB: 0,
+ },
+ },
+ })
+ ).toEqual({
+ coreSettings: undefined,
+ plugins: undefined,
+ internalBoot: {
+ bootMode: undefined,
+ skipped: undefined,
+ selection: {
+ poolName: undefined,
+ slotCount: undefined,
+ devices: undefined,
+ bootSizeMiB: 0,
+ updateBios: undefined,
+ poolMode: undefined,
+ },
+ },
+ });
+
+ expect(
+ normalizeOnboardingWizardDraft({
+ internalBoot: {
+ selection: {
+ slotCount: '2',
+ bootSizeMiB: -1,
+ },
+ },
+ })
+ ).toEqual({
+ coreSettings: undefined,
+ plugins: undefined,
+ internalBoot: {
+ bootMode: undefined,
+ skipped: undefined,
+ selection: {
+ poolName: undefined,
+ slotCount: 2,
+ devices: undefined,
+ bootSizeMiB: undefined,
+ updateBios: undefined,
+ poolMode: undefined,
+ },
+ },
+ });
+ });
});
diff --git a/web/src/components/Onboarding/onboardingWizardState.ts b/web/src/components/Onboarding/onboardingWizardState.ts
index ca1c671eee..beaf84f49e 100644
--- a/web/src/components/Onboarding/onboardingWizardState.ts
+++ b/web/src/components/Onboarding/onboardingWizardState.ts
@@ -112,6 +112,16 @@ const normalizeInternalBootDevices = (value: unknown): OnboardingInternalBootDev
.filter((device): device is OnboardingInternalBootDevice => device !== null)
: undefined;
+const normalizeSlotCount = (value: unknown): number | undefined => {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && (parsed === 1 || parsed === 2) ? parsed : undefined;
+};
+
+const normalizeBootSizeMiB = (value: unknown): number | undefined => {
+ const parsed = Number(value);
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
+};
+
const normalizeInternalBootSelection = (
value: unknown
): OnboardingInternalBootSelection | null | undefined => {
@@ -124,14 +134,12 @@ const normalizeInternalBootSelection = (
}
const candidate = value as Record;
- const parsedSlotCount = Number(candidate.slotCount);
- const parsedBootSizeMiB = Number(candidate.bootSizeMiB);
return {
poolName: normalizeString(candidate.poolName),
- slotCount: Number.isFinite(parsedSlotCount) ? parsedSlotCount : undefined,
+ slotCount: normalizeSlotCount(candidate.slotCount),
devices: normalizeInternalBootDevices(candidate.devices),
- bootSizeMiB: Number.isFinite(parsedBootSizeMiB) ? parsedBootSizeMiB : undefined,
+ bootSizeMiB: normalizeBootSizeMiB(candidate.bootSizeMiB),
updateBios: normalizeBoolean(candidate.updateBios),
poolMode: normalizePoolMode(candidate.poolMode),
};
From 35e82a0064e4d3a48940de7c9006306c54a63b3e Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 16:17:30 -0400
Subject: [PATCH 23/52] fix(onboarding-boot): ignore stale storage selections
- Purpose: keep internal boot summary and apply behavior aligned with the user's current top-level boot intent.
- Before: stale storage selections could remain in draft state after switching to USB or skipped boot mode, and parts of the summary/apply flow would still treat that stale data as active.
- Why that was a problem: users could see invalid summary states or risk applying an outdated storage configuration that no longer matched the current wizard selection.
- What this change accomplishes: stale storage selections are now ignored unless the draft is actively in storage mode and not skipped.
- How it works: gate both apply conversion helpers on the current boot mode, make the shared boot summary renderer prefer the current USB intent over leftover storage data, and add focused regression coverage in the onboarding and standalone summary flows.
---
.../OnboardingInternalBootStandalone.test.ts | 23 +++++++++++++++++++
.../Onboarding/OnboardingSummaryStep.test.ts | 21 +++++++++++++++++
.../buildBootConfigurationSummaryViewModel.ts | 4 ----
.../OnboardingInternalBoot.standalone.vue | 8 ++++++-
.../steps/OnboardingSummaryStep.vue | 17 +++++++++++---
5 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
index d4da1b3379..d469c5b2d0 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStandalone.test.ts
@@ -286,6 +286,29 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
});
+ it('ignores stale storage selection data when the current draft is usb', async () => {
+ configureDraftState.value = {
+ bootMode: 'usb',
+ skipped: false,
+ selection: {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ },
+ };
+
+ const wrapper = mountComponent();
+
+ await advanceToSummary(wrapper);
+ await confirmAndApply(wrapper);
+
+ expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
+ expect(wrapper.text()).toContain('No Updates Needed');
+ });
+
it('applies the selected internal boot configuration and records success', async () => {
configureDraftState.value = {
bootMode: 'storage',
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index ec8923d23c..879b78daae 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -1283,6 +1283,27 @@ describe('OnboardingSummaryStep', () => {
expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
});
+ it('ignores stale storage selection data when internal boot is now usb', async () => {
+ draftStore.bootMode = 'usb';
+ draftStore.internalBootSkipped = false;
+ draftStore.internalBootSelection = {
+ poolName: 'cache',
+ slotCount: 1,
+ devices: [createBootDevice('DISK-A', 500 * 1024 * 1024 * 1024, 'sda')],
+ bootSizeMiB: 16384,
+ updateBios: true,
+ poolMode: 'hybrid',
+ };
+
+ const { wrapper } = mountComponent();
+ await clickApply(wrapper);
+
+ expect(applyInternalBootSelectionMock).not.toHaveBeenCalled();
+ expect(wrapper.text()).toContain('Boot Method');
+ expect(wrapper.text()).toContain('USB/Flash Drive');
+ expect(wrapper.text()).toContain('Setup Applied');
+ });
+
it('applies internal boot configuration without reboot and records success', async () => {
draftStore.bootMode = 'storage';
draftStore.internalBootSelection = {
diff --git a/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts b/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
index b43590d096..8c1a0988a1 100644
--- a/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
+++ b/web/src/components/Onboarding/components/bootConfigurationSummary/buildBootConfigurationSummaryViewModel.ts
@@ -125,10 +125,6 @@ export const buildBootConfigurationSummaryViewModel = (
}
if (bootMode === 'usb') {
- if (selection) {
- return { kind: 'invalid', reason: 'UNEXPECTED_USB_SELECTION' };
- }
-
return {
kind: 'ready',
summary: {
diff --git a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
index 0c991df89a..ac72796b57 100644
--- a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
+++ b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
@@ -185,9 +185,15 @@ const addLogs = (entries: Array>) => {
};
const toInternalBootSelection = (draft: OnboardingInternalBootDraft): InternalBootSelection | null => {
+ if (draft.bootMode !== 'storage' || draft.skipped === true) {
+ return null;
+ }
+
const selection = draft.selection;
+ if (!selection) {
+ return null;
+ }
if (
- !selection ||
!selection.poolName ||
typeof selection.slotCount !== 'number' ||
!Array.isArray(selection.devices) ||
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index e6f0b20a60..4ec3ddb05a 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -110,6 +110,9 @@ const { result: availableLanguagesResult } = useQuery(GET_AVAILABLE_LANGUAGES_QU
});
const draftPluginsCount = computed(() => draftPlugins.value.length);
+const hasActiveStorageBootSelection = computed(
+ () => draftInternalBoot.value.bootMode === 'storage' && draftInternalBoot.value.skipped !== true
+);
const currentTimeZone = computed(() => {
return (
@@ -155,8 +158,16 @@ const formatBytes = (bytes: number) => {
};
const toAppliedInternalBootSelection = (
- selection: NonNullable
+ draft: OnboardingWizardDraft['internalBoot']
): InternalBootSelection | null => {
+ if (!draft || draft.bootMode !== 'storage' || draft.skipped === true) {
+ return null;
+ }
+
+ const selection = draft.selection;
+ if (!selection) {
+ return null;
+ }
if (
!selection.poolName ||
typeof selection.slotCount !== 'number' ||
@@ -927,8 +938,8 @@ const handleComplete = async () => {
}
// 3. Internal boot setup
- if (internalBootSelection.value) {
- const selection = toAppliedInternalBootSelection(internalBootSelection.value);
+ if (hasActiveStorageBootSelection.value) {
+ const selection = toAppliedInternalBootSelection(draftInternalBoot.value);
if (!selection) {
throw new Error('Internal boot selection is incomplete');
}
From 6512024a4f20173d8785ca0dc25e4364ea1f9e6c Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 16:49:16 -0400
Subject: [PATCH 24/52] fix(onboarding-modal): lock stepper during saves
- Purpose: prevent the onboarding stepper from racing an in-flight draft save during step transitions.
- Before: Continue and Back transitions were guarded by the save flag, but clicking the stepper could still move the modal while the draft save mutation was still pending.
- Why that was a problem: users could navigate the stepper mid-save and temporarily desynchronize the local modal step from the server-owned transition being persisted.
- What this change accomplishes: stepper clicks are now ignored while a transition save is in flight, while the close path remains available as an escape hatch.
- How it works: short-circuit goToStep when isSavingTransition is true and add a modal regression test that holds the save promise open and verifies the stepper does nothing until the save resolves.
---
.../Onboarding/OnboardingModal.test.ts | 66 ++++++++++++++++++-
.../components/Onboarding/OnboardingModal.vue | 4 ++
2 files changed, 67 insertions(+), 3 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts
index 20317a88a1..78d063762c 100644
--- a/web/__test__/components/Onboarding/OnboardingModal.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts
@@ -20,6 +20,7 @@ const {
themeStore,
saveOnboardingDraftMock,
cleanupOnboardingStorageMock,
+ stepperPropsRef,
} = vi.hoisted(() => ({
wizardRef: {
value: {
@@ -59,6 +60,7 @@ const {
},
saveOnboardingDraftMock: vi.fn(),
cleanupOnboardingStorageMock: vi.fn(),
+ stepperPropsRef: { value: null as Record | null },
}));
vi.mock('pinia', async (importOriginal) => {
@@ -93,9 +95,15 @@ vi.mock('~/components/Onboarding/components/OnboardingLoadingState.vue', () => (
vi.mock('~/components/Onboarding/OnboardingSteps.vue', () => ({
default: {
- props: ['steps', 'activeStepIndex'],
- template:
- '{{ steps.map((step) => step.id).join(",") }}|{{ activeStepIndex }}
',
+ props: ['steps', 'activeStepIndex', 'onStepClick'],
+ setup(props: Record) {
+ stepperPropsRef.value = props;
+ return { props };
+ },
+ template: `
+ {{ props.steps.map((step) => step.id).join(",") }}|{{ props.activeStepIndex }}
+ step-0
+ `,
},
}));
@@ -260,6 +268,17 @@ const mountComponent = () =>
const findButtonByText = (wrapper: ReturnType, text: string) =>
wrapper.findAll('button').find((button) => button.text().trim().toLowerCase() === text.toLowerCase());
+const createDeferred = () => {
+ let resolve!: (value: T | PromiseLike) => void;
+ let reject!: (reason?: unknown) => void;
+ const promise = new Promise((innerResolve, innerReject) => {
+ resolve = innerResolve;
+ reject = innerReject;
+ });
+
+ return { promise, resolve, reject };
+};
+
describe('OnboardingModal.vue', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -438,6 +457,47 @@ describe('OnboardingModal.vue', () => {
expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true);
});
+ it('ignores stepper navigation while a transition save is still in flight', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS', 'SUMMARY'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+
+ const deferred = createDeferred<{ data: { onboarding: { saveOnboardingDraft: boolean } } }>();
+ saveOnboardingDraftMock.mockReturnValueOnce(deferred.promise);
+
+ const wrapper = mountComponent();
+ await flushPromises();
+
+ await wrapper.get('[data-testid="settings-step-complete"]').trigger('click');
+ await flushPromises();
+
+ expect(saveOnboardingDraftMock).toHaveBeenCalledTimes(1);
+ expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-steps-click-0"]').trigger('click');
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="overview-step"]').exists()).toBe(false);
+
+ deferred.resolve({
+ data: {
+ onboarding: {
+ saveOnboardingDraft: true,
+ },
+ },
+ });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="plugins-step"]').exists()).toBe(true);
+ });
+
it('blocks navigation and offers a close path when a save fails', async () => {
wizardRef.value = {
currentStepId: 'CONFIGURE_SETTINGS',
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index b3b0d6f085..efc3e0f132 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -379,6 +379,10 @@ const transitionByOffset = async (offset: number) => {
};
const goToStep = (stepIndex: number) => {
+ if (isSavingTransition.value) {
+ return;
+ }
+
if (isInternalBootLocked.value && stepIndex < currentDynamicStepIndex.value) {
return;
}
From a509996438c6aa71bc08d54b362308bf2c7c8666 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 17:02:46 -0400
Subject: [PATCH 25/52] docs(onboarding): update internal boot draft example
- Purpose: keep the internal boot documentation aligned with the persisted draft shape used by the server-owned onboarding flow.
- Before: the doc still showed selection.devices as string IDs even though the tracker now stores device objects.
- Why that was a problem: readers would be pointed at the wrong JSON contract when debugging or reasoning about resume behavior.
- What this change accomplishes: the example now matches the persisted device shape.
- How it works: refresh the saved-shape example and add a short note explaining that device metadata is persisted so the summary views can render stable labels without re-fetching disk metadata.
---
docs/onboarding-internal-boot.md | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/docs/onboarding-internal-boot.md b/docs/onboarding-internal-boot.md
index dde8682ad3..6f395b6aff 100644
--- a/docs/onboarding-internal-boot.md
+++ b/docs/onboarding-internal-boot.md
@@ -36,7 +36,18 @@ The persisted internal-boot draft lives under:
"selection": {
"poolName": "cache",
"slotCount": 2,
- "devices": ["disk1", "disk2"],
+ "devices": [
+ {
+ "id": "disk1",
+ "sizeBytes": 512110190592,
+ "deviceName": "sda"
+ },
+ {
+ "id": "disk2",
+ "sizeBytes": 512110190592,
+ "deviceName": "sdb"
+ }
+ ],
"bootSizeMiB": 32768,
"updateBios": true,
"poolMode": "hybrid"
@@ -58,6 +69,7 @@ The persisted internal-boot draft lives under:
- whether the user explicitly skipped the step
- `selection`
- the storage pool choice when boot moves off USB
+ - selected devices are persisted as `{ id, sizeBytes, deviceName }` objects so the summary views can render stable labels without re-fetching disk metadata
`internalBootState` means:
From 5dacb246eeaa12eaba19a14c833e83a72a180c8d Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 17:05:55 -0400
Subject: [PATCH 26/52] docs(onboarding): document sparse wizard drafts
- Purpose: keep the onboarding architecture docs aligned with the sparse tracker draft model used by the server-owned wizard.
- Before: the docs implied that draft sections were always present as empty objects, which no longer matches the real tracker behavior.
- Why that was a problem: omitted sections and explicit empty values have different semantics in this flow, so the old example taught the wrong mental model.
- What this change accomplishes: the docs now show both an empty sparse tracker and a realistic non-empty saved draft example.
- How it works: replace the preinitialized draft example with as the default shape, explain the sparse-section rules, and add a second example that shows persisted core settings, plugin selections, and internal-boot state.
---
.../Onboarding/ONBOARDING_WIZARD.md | 59 +++++++++++++++++--
1 file changed, 54 insertions(+), 5 deletions(-)
diff --git a/web/src/components/Onboarding/ONBOARDING_WIZARD.md b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
index 6ac7107296..9e5656ed8f 100644
--- a/web/src/components/Onboarding/ONBOARDING_WIZARD.md
+++ b/web/src/components/Onboarding/ONBOARDING_WIZARD.md
@@ -50,7 +50,33 @@ How those fields are determined:
### Server State
-`api/src/unraid-api/config/onboarding-tracker.json` stores the durable wizard state:
+`api/src/unraid-api/config/onboarding-tracker.json` stores the durable wizard state.
+
+The draft is intentionally sparse:
+
+- a fresh/cleared tracker uses `draft: {}`
+- `coreSettings`, `plugins`, and `internalBoot` appear only after that slice is actually written
+- omitted sections are meaningful and should not be treated as preinitialized empty objects
+
+Empty/sparse example:
+
+```json
+{
+ "completed": false,
+ "completedAtVersion": null,
+ "forceOpen": false,
+ "draft": {},
+ "navigation": {
+ "currentStepId": "CONFIGURE_SETTINGS"
+ },
+ "internalBootState": {
+ "applyAttempted": false,
+ "applySucceeded": false
+ }
+}
+```
+
+Example with saved draft data:
```json
{
@@ -58,12 +84,35 @@ How those fields are determined:
"completedAtVersion": null,
"forceOpen": false,
"draft": {
- "coreSettings": {},
- "plugins": {},
- "internalBoot": {}
+ "coreSettings": {
+ "serverName": "Tower",
+ "timeZone": "America/New_York",
+ "theme": "white"
+ },
+ "plugins": {
+ "selectedIds": []
+ },
+ "internalBoot": {
+ "bootMode": "storage",
+ "skipped": false,
+ "selection": {
+ "poolName": "cache",
+ "slotCount": 1,
+ "devices": [
+ {
+ "id": "DISK-A",
+ "sizeBytes": 512110190592,
+ "deviceName": "sda"
+ }
+ ],
+ "bootSizeMiB": 16384,
+ "updateBios": true,
+ "poolMode": "hybrid"
+ }
+ }
},
"navigation": {
- "currentStepId": "CONFIGURE_SETTINGS"
+ "currentStepId": "SUMMARY"
},
"internalBootState": {
"applyAttempted": false,
From 4e762a4acb751743dce869dedd591bc34a7ba699 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 17:10:03 -0400
Subject: [PATCH 27/52] fix(onboarding-i18n): localize boot config warnings
- Purpose: keep the onboarding boot summary flows fully localized instead of mixing new English-only warning text into translated sessions.
- Before: the onboarding Summary invalid-config banner and the standalone internal-boot invalid-summary message were hard-coded strings.
- Why that was a problem: non-English sessions could see untranslated warning copy inside otherwise localized onboarding modals.
- What this change accomplishes: both warning messages now resolve through vue-i18n and share the onboarding summary translation namespace.
- How it works: replace the hard-coded strings with translated lookups and add the corresponding English locale keys so other locales safely fall back to en_US until translated.
---
.../standalone/OnboardingInternalBoot.standalone.vue | 3 +--
web/src/components/Onboarding/steps/OnboardingSummaryStep.vue | 3 +--
web/src/locales/en.json | 2 ++
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
index ac72796b57..1beff40e47 100644
--- a/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
+++ b/web/src/components/Onboarding/standalone/OnboardingInternalBoot.standalone.vue
@@ -58,8 +58,7 @@ const standaloneSteps: Array<{ id: StepId; required: boolean }> = [
const summaryT = (key: string, values?: Record) =>
t(`onboarding.summaryStep.${key}`, values ?? {});
-const standaloneSummaryInvalidMessage =
- 'Your boot configuration is incomplete. Go back and review the boot settings before applying changes.';
+const standaloneSummaryInvalidMessage = computed(() => summaryT('bootConfig.invalidStandalone'));
const isLocked = computed(() => internalBootState.value.applyAttempted);
diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
index 4ec3ddb05a..6ee7ab603e 100644
--- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
+++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue
@@ -494,8 +494,7 @@ const bootConfigurationSummaryState = computed(() =>
const hasInvalidBootConfiguration = computed(
() => bootConfigurationSummaryState.value.kind === 'invalid'
);
-const bootConfigurationInvalidMessage =
- 'This boot configuration is incomplete. Go back to Configure Boot to review it before applying changes.';
+const bootConfigurationInvalidMessage = computed(() => summaryT('bootConfig.invalid'));
const applyReadinessTimedOut = ref(false);
const APPLY_READINESS_TIMEOUT_MS = 10000;
let applyReadinessTimer: ReturnType | null = null;
diff --git a/web/src/locales/en.json b/web/src/locales/en.json
index b4b4ab9f26..0a97ca824f 100644
--- a/web/src/locales/en.json
+++ b/web/src/locales/en.json
@@ -260,6 +260,8 @@
"onboarding.summaryStep.bootConfig.bootReserved": "Boot Reserved",
"onboarding.summaryStep.bootConfig.updateBios": "Update BIOS",
"onboarding.summaryStep.bootConfig.devices": "Devices",
+ "onboarding.summaryStep.bootConfig.invalid": "This boot configuration is incomplete. Go back to Configure Boot to review it before applying changes.",
+ "onboarding.summaryStep.bootConfig.invalidStandalone": "Your boot configuration is incomplete. Go back and review the boot settings before applying changes.",
"onboarding.summaryStep.systemSetupLog": "System Setup Log",
"onboarding.summaryStep.readinessWarning": "We couldn't verify current settings from the server. You can still continue, but setup will apply changes in best-effort mode.",
"onboarding.summaryStep.driveWipe.title": "Confirm Drive Wipe",
From 4cbca106a7108cc8de8287af0869bd66591c524b Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 17:12:43 -0400
Subject: [PATCH 28/52] chore(onboarding-api): remove unused model imports
- Purpose: clean up dead onboarding model imports left behind after the draft boundary was simplified.
- Before: onboarding.model.ts still imported enum types that were no longer referenced after moving the draft payload to JSON.
- Why that was a problem: the unused imports triggered code-scanning noise and made the file look more coupled than it really is.
- What this change accomplishes: the resolver model now imports only the GraphQL types it actually uses.
- How it works: delete the unused onboarding draft enum imports and normalize the remaining import formatting.
---
.../graph/resolvers/onboarding/onboarding.model.ts | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
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 34845c6aed..da0dc0f77a 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts
@@ -18,11 +18,7 @@ import {
import { GraphQLJSON } from 'graphql-scalars';
import type { OnboardingDraft } from '@app/unraid-api/config/onboarding-tracker.model.js';
-import {
- OnboardingWizardBootMode,
- OnboardingWizardPoolMode,
- OnboardingWizardStepId,
-} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
+import { 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';
From e25c814f1d3e05b2cc91a41fbe0256eb2c6b593e Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Thu, 2 Apr 2026 17:16:27 -0400
Subject: [PATCH 29/52] test(onboarding-web): use nextTick for pool mode
reactivity
- Purpose: make the internal boot step test wait on the correct Vue reactivity boundary.
- Before: the test used flushPromises after toggling poolMode even though that path depends on Vue reactive updates, not a promise chain.
- Why that was a problem: the assertion barrier was broader than necessary and slightly more timing-dependent than the behavior under test.
- What this change accomplishes: the test now waits on nextTick when the reactive pool-mode toggle changes the selectable-device state.
- How it works: import nextTick, replace the two reactive waits in that scenario, and keep the rest of the test flow unchanged.
---
.../components/Onboarding/OnboardingInternalBootStep.test.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 0206333cb7..0c1461442b 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
import { flushPromises, mount } from '@vue/test-utils';
import { REFRESH_INTERNAL_BOOT_CONTEXT_MUTATION } from '@/components/Onboarding/graphql/refreshInternalBootContext.mutation';
@@ -537,7 +538,7 @@ describe('OnboardingInternalBootStep', () => {
);
vm.poolMode = 'dedicated';
- await flushPromises();
+ await nextTick();
expect(vm.getDeviceSelectItems(0)).toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })])
@@ -546,7 +547,7 @@ describe('OnboardingInternalBootStep', () => {
await flushPromises();
vm.poolMode = 'hybrid';
- await flushPromises();
+ await nextTick();
expect(vm.getDeviceSelectItems(0)).not.toEqual(
expect.arrayContaining([expect.objectContaining({ value: 'DEDICATED-6GIB' })])
From c73c02378b20f3eaf25bf6be01f929d5a9255915 Mon Sep 17 00:00:00 2001
From: Ajit Mehrotra
Date: Fri, 3 Apr 2026 15:37:50 -0400
Subject: [PATCH 30/52] refactor(onboarding): simplify step state ownership
- Purpose: make onboarding query and transition states easier to reason about by keeping shell concerns in the modal and step-visible failures inside the active step.
- Before: steps mixed best-effort query fallbacks with step-specific loaders, the modal rendered a separate top save-failure banner, and closeOnboarding carried an optional SAVE_FAILURE GraphQL reason used only for extra metadata logging.
- Why that was a problem: users could see overlapping modal and step errors at the same time, summary could continue with partial assumptions, and the close mutation/schema carried extra onboarding-only plumbing that did not affect behavior.
- What changed: added a shared strict step query gate, made query-backed steps block on required data with embedded loading/error states, moved transition-save failures into the step surface, and removed the close-reason enum/input plumbing from web and API.
- How it works: the modal now owns only shell loading and exit confirmation, each step owns its own query and submit failures, closeOnboarding is a plain close again, and the updated onboarding tests plus regenerated GraphQL types cover the stricter flow.
---
.../resolvers/onboarding/onboarding.model.ts | 19 +-
.../onboarding/onboarding.mutation.spec.ts | 18 +-
.../onboarding/onboarding.mutation.ts | 11 +-
.../OnboardingCoreSettingsStep.test.ts | 62 +-
.../OnboardingInternalBootStep.test.ts | 77 ++
.../Onboarding/OnboardingModal.test.ts | 76 +-
.../Onboarding/OnboardingPluginsStep.test.ts | 38 +-
.../OnboardingStepQueryGate.test.ts | 64 ++
.../Onboarding/OnboardingSummaryStep.test.ts | 194 ++---
.../store/onboardingModalVisibility.test.ts | 1 +
web/components.d.ts | 1 +
.../components/Onboarding/OnboardingModal.vue | 43 +-
.../components/OnboardingStepQueryGate.vue | 83 ++
.../useOnboardingStepQueryState.ts | 55 ++
.../graphql/closeOnboarding.mutation.ts | 4 +-
.../steps/OnboardingCoreSettingsStep.vue | 354 +++++----
.../steps/OnboardingInternalBootStep.vue | 690 ++++++++---------
.../steps/OnboardingLicenseStep.vue | 11 +
.../steps/OnboardingOverviewStep.vue | 11 +
.../steps/OnboardingPluginsStep.vue | 165 ++--
.../steps/OnboardingSummaryStep.vue | 716 +++++++++---------
.../store/onboardingModalVisibility.ts | 12 +-
web/src/composables/gql/gql.ts | 6 +-
web/src/composables/gql/graphql.ts | 6 +-
web/src/composables/gql/index.ts | 4 +-
web/src/locales/en.json | 5 +
26 files changed, 1548 insertions(+), 1178 deletions(-)
create mode 100644 web/__test__/components/Onboarding/OnboardingStepQueryGate.test.ts
create mode 100644 web/src/components/Onboarding/components/OnboardingStepQueryGate.vue
create mode 100644 web/src/components/Onboarding/composables/useOnboardingStepQueryState.ts
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 da0dc0f77a..2859794dc6 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, registerEnumType } from '@nestjs/graphql';
+import { Field, InputType, Int, ObjectType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
@@ -22,15 +22,6 @@ import { OnboardingWizardStepId } from '@app/unraid-api/graph/resolvers/customiz
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',
})
@@ -338,14 +329,6 @@ export class SaveOnboardingDraftInput {
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.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
index 2e09713b63..2d3ac77591 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts
@@ -11,10 +11,7 @@ import {
OnboardingStatus,
OnboardingWizardStepId,
} from '@app/unraid-api/graph/resolvers/customization/activation-code.model.js';
-import {
- CloseOnboardingReason,
- CreateInternalBootPoolInput,
-} from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
+import { CreateInternalBootPoolInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js';
import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js';
describe('OnboardingMutationsResolver', () => {
@@ -160,19 +157,6 @@ describe('OnboardingMutationsResolver', () => {
expect(onboardingService.getOnboardingResponse).toHaveBeenCalledWith();
});
- it('logs save-failure close reasons before delegating closeOnboarding', async () => {
- const loggerWarn = vi.fn();
- (resolver as unknown as { logger: { warn: (message: string) => void } }).logger.warn =
- loggerWarn;
-
- await expect(
- resolver.closeOnboarding({ reason: CloseOnboardingReason.SAVE_FAILURE })
- ).resolves.toEqual(defaultOnboardingResponse);
-
- expect(loggerWarn).toHaveBeenCalledWith('closeOnboarding invoked with reason=SAVE_FAILURE');
- expect(onboardingService.closeOnboarding).toHaveBeenCalledTimes(1);
- });
-
it('delegates bypassOnboarding through the onboarding service', async () => {
const response = {
...defaultOnboardingResponse,
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 a0749c3135..837c8f602f 100644
--- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts
+++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts
@@ -1,4 +1,3 @@
-import { Logger } from '@nestjs/common';
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
@@ -11,7 +10,6 @@ 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,
@@ -21,8 +19,6 @@ import {
@Resolver(() => OnboardingMutations)
export class OnboardingMutationsResolver {
- private readonly logger = new Logger(OnboardingMutationsResolver.name);
-
constructor(
private readonly onboardingOverrides: OnboardingOverrideService,
private readonly onboardingService: OnboardingService,
@@ -72,12 +68,7 @@ export class OnboardingMutationsResolver {
action: AuthAction.UPDATE_ANY,
resource: Resource.WELCOME,
})
- async closeOnboarding(
- @Args('input', { nullable: true }) input?: CloseOnboardingInput
- ): Promise {
- if (input?.reason) {
- this.logger.warn(`closeOnboarding invoked with reason=${input.reason}`);
- }
+ async closeOnboarding(): Promise {
await this.onboardingService.closeOnboarding();
return this.onboardingService.getOnboardingResponse();
}
diff --git a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
index 29fcff7c71..be8610196b 100644
--- a/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingCoreSettingsStep.test.ts
@@ -13,11 +13,16 @@ const {
onboardingStore,
setCoreSettingsMock,
timeZoneOptionsResult,
+ timeZoneOptionsError,
+ refetchTimeZoneOptionsMock,
languagesResult,
languagesLoading,
languagesError,
+ refetchLanguagesMock,
coreOnResultHandlers,
coreSettingsResult,
+ coreSettingsError,
+ refetchCoreSettingsMock,
useQueryMock,
} = vi.hoisted(() => ({
setCoreSettingsMock: vi.fn(),
@@ -43,6 +48,8 @@ const {
],
},
},
+ timeZoneOptionsError: { value: null as unknown },
+ refetchTimeZoneOptionsMock: vi.fn().mockResolvedValue(undefined),
languagesResult: {
value: {
customization: {
@@ -55,8 +62,11 @@ const {
},
languagesLoading: { value: false },
languagesError: { value: null as unknown },
+ refetchLanguagesMock: vi.fn().mockResolvedValue(undefined),
coreOnResultHandlers: [] as Array<(payload: unknown) => void>,
coreSettingsResult: { value: null as unknown },
+ coreSettingsError: { value: null as unknown },
+ refetchCoreSettingsMock: vi.fn().mockResolvedValue(undefined),
useQueryMock: vi.fn(),
}));
@@ -69,6 +79,10 @@ vi.mock('@unraid/ui', () => ({
template:
' {{ text }} ',
},
+ Spinner: {
+ name: 'Spinner',
+ template: '
',
+ },
}));
vi.mock('@vvo/tzdb', () => ({
@@ -104,11 +118,16 @@ vi.mock('@vue/apollo-composable', async () => {
const setupQueryMocks = () => {
useQueryMock.mockImplementation((doc: unknown) => {
if (doc === TIME_ZONE_OPTIONS_QUERY) {
- return { result: timeZoneOptionsResult };
+ return {
+ result: timeZoneOptionsResult,
+ error: timeZoneOptionsError,
+ refetch: refetchTimeZoneOptionsMock,
+ };
}
if (doc === GET_CORE_SETTINGS_QUERY) {
return {
result: coreSettingsResult,
+ error: coreSettingsError,
onResult: (cb: (payload: unknown) => void) => {
coreOnResultHandlers.push((payload: unknown) => {
const candidate = payload as { data?: unknown };
@@ -116,6 +135,7 @@ const setupQueryMocks = () => {
cb(payload);
});
},
+ refetch: refetchCoreSettingsMock,
};
}
if (doc === GET_AVAILABLE_LANGUAGES_QUERY) {
@@ -123,6 +143,7 @@ const setupQueryMocks = () => {
result: languagesResult,
loading: languagesLoading,
error: languagesError,
+ refetch: refetchLanguagesMock,
};
}
return { result: { value: null } };
@@ -144,6 +165,7 @@ const mountComponent = (props: Record = {}) => {
}
: null,
onComplete,
+ onCloseOnboarding: vi.fn(),
showBack: true,
...props,
},
@@ -199,12 +221,48 @@ describe('OnboardingCoreSettingsStep', () => {
draftStore.coreSettingsInitialized = false;
onboardingStore.completed.value = true;
onboardingStore.loading.value = false;
- coreSettingsResult.value = null;
+ coreSettingsResult.value = {
+ server: { name: 'Tower', comment: '' },
+ vars: { name: 'Tower', useSsh: false, localTld: 'local' },
+ display: { theme: 'white', locale: 'en_US' },
+ systemTime: { timeZone: 'UTC' },
+ info: { primaryNetwork: { ipAddress: '192.168.1.2' } },
+ };
+ coreSettingsError.value = null;
+ timeZoneOptionsError.value = null;
languagesLoading.value = false;
languagesError.value = null;
});
+ it('blocks the form behind a loading gate until the required queries are ready', async () => {
+ coreSettingsResult.value = null;
+ const { wrapper } = mountComponent();
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.find('input').exists()).toBe(false);
+ });
+
+ it('shows retry and close actions when a required query errors', async () => {
+ const onCloseOnboarding = vi.fn();
+ coreSettingsResult.value = null;
+ coreSettingsError.value = new Error('offline');
+
+ const { wrapper } = mountComponent({ onCloseOnboarding });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click');
+ await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click');
+
+ expect(refetchTimeZoneOptionsMock).toHaveBeenCalledTimes(1);
+ expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1);
+ expect(refetchLanguagesMock).toHaveBeenCalledTimes(1);
+ expect(onCloseOnboarding).toHaveBeenCalledTimes(1);
+ });
+
it('prefers browser timezone over API on initial setup when draft timezone is empty', async () => {
onboardingStore.completed.value = false;
diff --git a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
index 0c1461442b..bcc9ad0ffb 100644
--- a/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingInternalBootStep.test.ts
@@ -73,6 +73,10 @@ vi.mock('@unraid/ui', () => ({
props: ['items', 'type', 'collapsible', 'class'],
template: `
`,
},
+ Spinner: {
+ name: 'Spinner',
+ template: '
',
+ },
}));
vi.mock('@vue/apollo-composable', () => ({
@@ -126,6 +130,7 @@ const mountComponent = () =>
selection: draftStore.internalBootSelection,
},
onComplete: vi.fn(),
+ onCloseOnboarding: vi.fn(),
showBack: true,
},
global: {
@@ -205,6 +210,78 @@ describe('OnboardingInternalBootStep', () => {
contextResult.value = null;
});
+ it('blocks the step behind a loading gate until the internal boot query is ready', async () => {
+ draftStore.bootMode = 'storage';
+
+ const wrapper = mountComponent();
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="internal-boot-eligibility-panel"]').exists()).toBe(false);
+ });
+
+ it('shows retry and close actions when the internal boot query fails', async () => {
+ draftStore.bootMode = 'storage';
+ contextError.value = new Error('offline');
+ const onCloseOnboarding = vi.fn();
+
+ const wrapper = mount(OnboardingInternalBootStep, {
+ props: {
+ initialDraft: {
+ bootMode: draftStore.bootMode,
+ skipped: false,
+ selection: draftStore.internalBootSelection,
+ },
+ onComplete: vi.fn(),
+ onCloseOnboarding,
+ showBack: true,
+ },
+ global: {
+ plugins: [createTestI18n()],
+ stubs: {
+ UButton: {
+ props: ['disabled'],
+ emits: ['click'],
+ template: ' ',
+ },
+ UAlert: {
+ inheritAttrs: true,
+ props: ['title', 'description'],
+ template:
+ ' {{ title }} {{ description }}
',
+ },
+ UCheckbox: {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template:
+ ' ',
+ },
+ UInput: {
+ props: ['modelValue', 'type', 'disabled', 'maxlength', 'min', 'max'],
+ emits: ['update:modelValue'],
+ template:
+ ' ',
+ },
+ USelectMenu: {
+ props: ['modelValue', 'items', 'disabled', 'placeholder'],
+ emits: ['update:modelValue'],
+ template:
+ '{{ placeholder }} {{ item.label }} ',
+ },
+ },
+ },
+ });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click');
+ await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click');
+
+ expect(refetchContextMock).toHaveBeenCalledTimes(1);
+ expect(onCloseOnboarding).toHaveBeenCalledTimes(1);
+ });
+
it('renders all available server and disk eligibility codes when storage boot is blocked', async () => {
draftStore.bootMode = 'storage';
contextResult.value = buildContext({
diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts
index 78d063762c..4b7423ec75 100644
--- a/web/__test__/components/Onboarding/OnboardingModal.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts
@@ -119,22 +119,31 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
],
stepComponents: {
OVERVIEW: {
- props: ['onComplete', 'onSkipSetup'],
+ props: ['onComplete', 'onSkipSetup', 'saveError'],
template:
- 'next skip
',
+ '',
},
CONFIGURE_SETTINGS: {
- props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'isSavingStep', 'saveError'],
+ props: [
+ 'initialDraft',
+ 'onComplete',
+ 'onBack',
+ 'onCloseOnboarding',
+ 'showBack',
+ 'isSavingStep',
+ 'saveError',
+ ],
template: `
{{ saveError }}
next
back
+
close
`,
},
CONFIGURE_BOOT: {
- props: ['initialDraft', 'onComplete', 'onBack', 'showBack', 'saveError'],
+ props: ['initialDraft', 'onComplete', 'onBack', 'onCloseOnboarding', 'showBack', 'saveError'],
template: `
{{ saveError }}
@@ -160,7 +169,15 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
`,
},
ADD_PLUGINS: {
- props: ['initialDraft', 'onComplete', 'onSkip', 'onBack', 'showBack', 'saveError'],
+ props: [
+ 'initialDraft',
+ 'onComplete',
+ 'onSkip',
+ 'onBack',
+ 'onCloseOnboarding',
+ 'showBack',
+ 'saveError',
+ ],
template: `
{{ saveError }}
@@ -171,9 +188,9 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
`,
},
ACTIVATE_LICENSE: {
- props: ['onComplete', 'onBack', 'showBack'],
+ props: ['onComplete', 'onBack', 'showBack', 'saveError'],
template:
- '
next back
',
+ '
',
},
SUMMARY: {
props: [
@@ -182,10 +199,13 @@ vi.mock('~/components/Onboarding/stepRegistry', () => ({
'onInternalBootStateChange',
'onComplete',
'onBack',
+ 'onCloseOnboarding',
'showBack',
+ 'saveError',
],
template: `
+
{{ saveError }}
lock
next
back
@@ -518,16 +538,21 @@ describe('OnboardingModal.vue', () => {
expect(wrapper.find('[data-testid="settings-step"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="settings-step-error"]').text()).not.toBe('');
+ expect(
+ wrapper.text().match(/We couldn't finish onboarding right now\. Please try again\./g)?.length ?? 0
+ ).toBe(1);
- const closeButton = wrapper
- .findAll('button')
- .find((button) => button.text().trim().toLowerCase().includes('exit'));
- expect(closeButton).toBeTruthy();
+ await wrapper.find('button[aria-label="Close onboarding"]').trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Exit onboarding?');
- await closeButton!.trigger('click');
+ const exitButton = findButtonByText(wrapper, 'Exit setup');
+ expect(exitButton).toBeTruthy();
+ await exitButton!.trigger('click');
await flushPromises();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith('SAVE_FAILURE');
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith();
});
it('opens exit confirmation and closes through the backend-owned close path', async () => {
@@ -545,11 +570,32 @@ describe('OnboardingModal.vue', () => {
await exitButton!.trigger('click');
await flushPromises();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith(undefined);
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith();
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
+ it('reuses the existing exit flow when a step requests close onboarding', async () => {
+ wizardRef.value = {
+ currentStepId: 'CONFIGURE_SETTINGS',
+ visibleStepIds: ['OVERVIEW', 'CONFIGURE_SETTINGS', 'ADD_PLUGINS'],
+ draft: {},
+ internalBootState: {
+ applyAttempted: false,
+ applySucceeded: false,
+ },
+ };
+
+ const wrapper = mountComponent();
+ await flushPromises();
+
+ await wrapper.get('[data-testid="settings-step-close"]').trigger('click');
+ await flushPromises();
+
+ expect(wrapper.text()).toContain('Exit onboarding?');
+ expect(onboardingModalStoreState.closeModal).not.toHaveBeenCalled();
+ });
+
it('hides exit controls and back navigation when internal boot is locked', async () => {
wizardRef.value = {
currentStepId: 'CONFIGURE_SETTINGS',
@@ -624,7 +670,7 @@ describe('OnboardingModal.vue', () => {
await flushPromises();
expect(saveOnboardingDraftMock).not.toHaveBeenCalled();
- expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith(undefined);
+ expect(onboardingModalStoreState.closeModal).toHaveBeenCalledWith();
expect(reloadSpy).toHaveBeenCalledTimes(1);
});
});
diff --git a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
index c536553d3a..872184b87d 100644
--- a/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingPluginsStep.test.ts
@@ -6,7 +6,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import OnboardingPluginsStep from '~/components/Onboarding/steps/OnboardingPluginsStep.vue';
import { createTestI18n } from '../../utils/i18n';
-const { draftStore, installedPluginsLoading, installedPluginsResult, useQueryMock } = vi.hoisted(() => ({
+const {
+ draftStore,
+ installedPluginsLoading,
+ installedPluginsResult,
+ installedPluginsError,
+ refetchInstalledPluginsMock,
+ useQueryMock,
+} = vi.hoisted(() => ({
draftStore: {
selectedPlugins: new Set
(),
},
@@ -18,6 +25,10 @@ const { draftStore, installedPluginsLoading, installedPluginsResult, useQueryMoc
installedUnraidPlugins: [],
} as { installedUnraidPlugins: string[] } | null,
},
+ installedPluginsError: {
+ value: null as unknown,
+ },
+ refetchInstalledPluginsMock: vi.fn().mockResolvedValue(undefined),
useQueryMock: vi.fn(),
}));
@@ -52,12 +63,15 @@ describe('OnboardingPluginsStep', () => {
installedPluginsResult.value = {
installedUnraidPlugins: [],
};
+ installedPluginsError.value = null;
useQueryMock.mockImplementation((query: unknown) => {
if (query === INSTALLED_UNRAID_PLUGINS_QUERY) {
return {
result: installedPluginsResult,
loading: installedPluginsLoading,
+ error: installedPluginsError,
+ refetch: refetchInstalledPluginsMock,
};
}
return { result: { value: null } };
@@ -69,6 +83,7 @@ describe('OnboardingPluginsStep', () => {
onComplete: vi.fn(),
onBack: vi.fn(),
onSkip: vi.fn(),
+ onCloseOnboarding: vi.fn(),
initialDraft:
draftStore.selectedPlugins.size > 0
? {
@@ -173,12 +188,23 @@ describe('OnboardingPluginsStep', () => {
await flushPromises();
- const nextButton = wrapper
- .findAll('button')
- .find((button) => button.text().toLowerCase().includes('next'));
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ });
- expect(nextButton).toBeTruthy();
- expect((nextButton!.element as HTMLButtonElement).disabled).toBe(true);
+ it('shows retry and close actions when the installed plugins query fails', async () => {
+ installedPluginsError.value = new Error('offline');
+ const onCloseOnboarding = vi.fn();
+
+ const { wrapper } = mountComponent({ onCloseOnboarding });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click');
+ await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click');
+
+ expect(refetchInstalledPluginsMock).toHaveBeenCalledTimes(1);
+ expect(onCloseOnboarding).toHaveBeenCalledTimes(1);
});
it('skip clears selection and calls onSkip', async () => {
diff --git a/web/__test__/components/Onboarding/OnboardingStepQueryGate.test.ts b/web/__test__/components/Onboarding/OnboardingStepQueryGate.test.ts
new file mode 100644
index 0000000000..adc170ad0f
--- /dev/null
+++ b/web/__test__/components/Onboarding/OnboardingStepQueryGate.test.ts
@@ -0,0 +1,64 @@
+import { mount } from '@vue/test-utils';
+
+import { describe, expect, it, vi } from 'vitest';
+
+import OnboardingStepQueryGate from '~/components/Onboarding/components/OnboardingStepQueryGate.vue';
+import { createTestI18n } from '../../utils/i18n';
+
+vi.mock('@unraid/ui', () => ({
+ BrandButton: {
+ props: ['text'],
+ emits: ['click'],
+ template: '{{ text }} ',
+ },
+ Spinner: {
+ name: 'Spinner',
+ template: '
',
+ },
+}));
+
+describe('OnboardingStepQueryGate', () => {
+ const mountComponent = (props: Record = {}) =>
+ mount(OnboardingStepQueryGate, {
+ props,
+ slots: {
+ default: 'ready
',
+ },
+ global: {
+ plugins: [createTestI18n()],
+ },
+ });
+
+ it('renders the shared loading state while loading', () => {
+ const wrapper = mountComponent({ loading: true });
+
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="ready-content"]').exists()).toBe(false);
+ });
+
+ it('renders retry and close actions when an error is present', async () => {
+ const onRetry = vi.fn();
+ const onCloseOnboarding = vi.fn();
+ const wrapper = mountComponent({
+ error: new Error('offline'),
+ onRetry,
+ onCloseOnboarding,
+ });
+
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click');
+ await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click');
+
+ expect(onRetry).toHaveBeenCalledTimes(1);
+ expect(onCloseOnboarding).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders slot content once the query state is ready', () => {
+ const wrapper = mountComponent();
+
+ expect(wrapper.find('[data-testid="ready-content"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(false);
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(false);
+ });
+});
diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
index 879b78daae..8a2793d298 100644
--- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
+++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts
@@ -31,11 +31,18 @@ const {
isFreshInstallRef,
activationCodeRef,
coreSettingsResult,
+ coreSettingsLoading,
coreSettingsError,
internalBootContextResult,
installedPluginsResult,
+ installedPluginsLoading,
+ installedPluginsError,
availableLanguagesResult,
+ availableLanguagesLoading,
+ availableLanguagesError,
+ refetchCoreSettingsMock,
refetchInstalledPluginsMock,
+ refetchAvailableLanguagesMock,
setModalHiddenMock,
updateSystemTimeMock,
updateServerIdentityMock,
@@ -84,9 +91,12 @@ const {
coreSettingsResult: {
value: null as unknown,
},
+ coreSettingsLoading: { value: false },
coreSettingsError: { value: null as unknown },
internalBootContextResult: { value: null as GetInternalBootContextQuery | null },
installedPluginsResult: { value: { installedUnraidPlugins: [] as string[] } },
+ installedPluginsLoading: { value: false },
+ installedPluginsError: { value: null as unknown },
availableLanguagesResult: {
value: {
customization: {
@@ -97,7 +107,11 @@ const {
},
},
},
+ availableLanguagesLoading: { value: false },
+ availableLanguagesError: { value: null as unknown },
+ refetchCoreSettingsMock: vi.fn().mockResolvedValue(undefined),
refetchInstalledPluginsMock: vi.fn().mockResolvedValue(undefined),
+ refetchAvailableLanguagesMock: vi.fn().mockResolvedValue(undefined),
setModalHiddenMock: vi.fn(),
updateSystemTimeMock: vi.fn().mockResolvedValue({}),
updateServerIdentityMock: vi.fn().mockResolvedValue({}),
@@ -138,6 +152,10 @@ vi.mock('@unraid/ui', () => ({
props: ['items', 'type', 'collapsible', 'class'],
template: `
`,
},
+ Spinner: {
+ name: 'Spinner',
+ template: '
',
+ },
}));
vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({
@@ -217,7 +235,12 @@ const setupApolloMocks = () => {
useQueryMock.mockImplementation((doc: unknown) => {
if (doc === GET_CORE_SETTINGS_QUERY) {
- return { result: coreSettingsResult, error: coreSettingsError };
+ return {
+ result: coreSettingsResult,
+ loading: coreSettingsLoading,
+ error: coreSettingsError,
+ refetch: refetchCoreSettingsMock,
+ };
}
if (doc === GetInternalBootContextDocument) {
return { result: internalBootContextResult };
@@ -225,11 +248,18 @@ const setupApolloMocks = () => {
if (doc === INSTALLED_UNRAID_PLUGINS_QUERY) {
return {
result: installedPluginsResult,
+ loading: installedPluginsLoading,
+ error: installedPluginsError,
refetch: refetchInstalledPluginsMock,
};
}
if (doc === GET_AVAILABLE_LANGUAGES_QUERY) {
- return { result: availableLanguagesResult };
+ return {
+ result: availableLanguagesResult,
+ loading: availableLanguagesLoading,
+ error: availableLanguagesError,
+ refetch: refetchAvailableLanguagesMock,
+ };
}
return { result: { value: null } };
});
@@ -266,6 +296,7 @@ const mountComponent = (props: Record = {}) => {
setInternalBootApplySucceededMock(state.applySucceeded);
}),
onComplete,
+ onCloseOnboarding: vi.fn(),
showBack: true,
...props,
},
@@ -433,6 +464,7 @@ describe('OnboardingSummaryStep', () => {
registrationStateRef.value = 'ENOKEYFILE';
isFreshInstallRef.value = true;
activationCodeRef.value = null;
+ coreSettingsLoading.value = false;
coreSettingsError.value = null;
coreSettingsResult.value = {
vars: { name: 'Tower', useSsh: false, localTld: 'local' },
@@ -468,7 +500,11 @@ describe('OnboardingSummaryStep', () => {
],
},
};
+ installedPluginsLoading.value = false;
+ installedPluginsError.value = null;
installedPluginsResult.value = { installedUnraidPlugins: [] };
+ availableLanguagesLoading.value = false;
+ availableLanguagesError.value = null;
availableLanguagesResult.value = {
customization: {
availableLanguages: [
@@ -503,6 +539,35 @@ describe('OnboardingSummaryStep', () => {
refetchInstalledPluginsMock.mockResolvedValue(undefined);
});
+ it('blocks summary apply behind a loading gate until all required queries are ready', async () => {
+ coreSettingsResult.value = null;
+ coreSettingsLoading.value = true;
+
+ const { wrapper } = mountComponent();
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.find('[data-testid="brand-button"]').exists()).toBe(false);
+ });
+
+ it('shows retry and close actions when a required summary query fails', async () => {
+ coreSettingsError.value = new Error('offline');
+ const onCloseOnboarding = vi.fn();
+
+ const { wrapper } = mountComponent({ onCloseOnboarding });
+ await flushPromises();
+
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+
+ await wrapper.get('[data-testid="onboarding-step-query-retry"]').trigger('click');
+ await wrapper.get('[data-testid="onboarding-step-query-close"]').trigger('click');
+
+ expect(refetchCoreSettingsMock).toHaveBeenCalledTimes(1);
+ expect(refetchInstalledPluginsMock).toHaveBeenCalledTimes(1);
+ expect(refetchAvailableLanguagesMock).toHaveBeenCalledTimes(1);
+ expect(onCloseOnboarding).toHaveBeenCalledTimes(1);
+ });
+
it.each([
{
caseName: 'skips install when plugin is already present',
@@ -915,117 +980,40 @@ describe('OnboardingSummaryStep', () => {
}
});
- it('applies trusted defaults + draft values when baseline query is down', async () => {
+ it('does not apply changes when the baseline queries fail', async () => {
coreSettingsResult.value = null;
coreSettingsError.value = new Error('Graphql is offline.');
- draftStore.serverName = 'MyTower';
- draftStore.serverDescription = 'Edge host';
- draftStore.selectedTimeZone = 'America/New_York';
- draftStore.selectedTheme = 'black';
- draftStore.selectedLanguage = 'en_US';
- draftStore.useSsh = true;
const { wrapper } = mountComponent();
- await clickApply(wrapper);
+ await flushPromises();
- expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'America/New_York' } });
- expect(updateServerIdentityMock).toHaveBeenCalledWith({
- name: 'MyTower',
- comment: 'Edge host',
- });
- expect(setThemeMock).toHaveBeenCalledWith({ theme: 'black' });
- expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' });
- expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: true, port: 22 });
+ expect(wrapper.find('[data-testid="onboarding-step-query-error"]').exists()).toBe(true);
+ expect(updateSystemTimeMock).not.toHaveBeenCalled();
+ expect(updateServerIdentityMock).not.toHaveBeenCalled();
+ expect(setThemeMock).not.toHaveBeenCalled();
+ expect(setLocaleMock).not.toHaveBeenCalled();
+ expect(updateSshSettingsMock).not.toHaveBeenCalled();
});
- it('applies trusted defaults when baseline query is down and draft values are empty', async () => {
+ it('stays blocked instead of falling back after waiting for missing baseline data', async () => {
coreSettingsResult.value = null;
- coreSettingsError.value = new Error('Graphql is offline.');
- draftStore.serverName = '';
- draftStore.serverDescription = '';
- draftStore.selectedTimeZone = '';
- draftStore.selectedTheme = '';
- draftStore.selectedLanguage = '';
- draftStore.useSsh = false;
-
- const { wrapper } = mountComponent();
- await clickApply(wrapper);
-
- expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } });
- expect(updateServerIdentityMock).toHaveBeenCalledWith({
- name: 'Tower',
- comment: '',
- });
- expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' });
- expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' });
- expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 });
- });
-
- it('keeps best-effort fallback path once readiness times out before baseline is ready', async () => {
- coreSettingsResult.value = null;
- coreSettingsError.value = null;
- draftStore.serverName = 'Tower';
- draftStore.serverDescription = '';
- draftStore.selectedTimeZone = 'UTC';
- draftStore.selectedTheme = 'white';
- draftStore.selectedLanguage = 'en_US';
- draftStore.useSsh = false;
+ coreSettingsLoading.value = true;
const { wrapper } = mountComponent();
await vi.advanceTimersByTimeAsync(10000);
-
- coreSettingsResult.value = {
- vars: { name: 'Tower', useSsh: false, localTld: 'local' },
- server: { name: 'Tower', comment: '' },
- display: { theme: 'white', locale: 'en_US' },
- systemTime: { timeZone: 'UTC' },
- info: { primaryNetwork: { ipAddress: '192.168.1.2' } },
- };
await flushPromises();
- await clickApply(wrapper);
-
- expect(updateSystemTimeMock).toHaveBeenCalledWith({ input: { timeZone: 'UTC' } });
- expect(updateServerIdentityMock).toHaveBeenCalledWith({
- name: 'Tower',
- comment: '',
- });
- expect(setThemeMock).toHaveBeenCalledWith({ theme: 'white' });
- expect(setLocaleMock).toHaveBeenCalledWith({ locale: 'en_US' });
- expect(updateSshSettingsMock).toHaveBeenCalledWith({ enabled: false, port: 22 });
- expect(wrapper.text()).toContain(
- 'Baseline settings unavailable. Applying trusted defaults + draft values without diff checks.'
- );
+ expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true);
+ expect(wrapper.text()).not.toContain('Baseline settings unavailable');
});
- it.each([
- {
- caseName: 'baseline available — shows success (completion deferred to NextSteps)',
- apply: () => {},
- assertExpected: (wrapper: ReturnType['wrapper']) => {
- expect(completeOnboardingMock).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain('No Updates Needed');
- expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode');
- },
- },
- {
- caseName: 'baseline unavailable — shows best-effort (completion deferred to NextSteps)',
- apply: () => {
- coreSettingsResult.value = null;
- coreSettingsError.value = new Error('Graphql is offline.');
- },
- assertExpected: (wrapper: ReturnType['wrapper']) => {
- expect(completeOnboardingMock).not.toHaveBeenCalled();
- expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode');
- },
- },
- ])('follows apply-only result matrix ($caseName)', async (scenario) => {
- scenario.apply();
-
+ it('shows the normal success result when required queries are ready', async () => {
const { wrapper } = mountComponent();
await clickApply(wrapper);
- scenario.assertExpected(wrapper);
+ expect(completeOnboardingMock).not.toHaveBeenCalled();
+ expect(wrapper.text()).toContain('No Updates Needed');
+ expect(wrapper.text()).not.toContain('Setup Saved in Best-Effort Mode');
});
it('keeps the success dialog open after apply instead of advancing immediately', async () => {
@@ -1118,22 +1106,6 @@ describe('OnboardingSummaryStep', () => {
expect(wrapper.text()).not.toContain('Setup Applied with Warnings');
});
- it('shows result dialog in offline mode and advances only after OK', async () => {
- coreSettingsResult.value = null;
- coreSettingsError.value = new Error('Graphql is offline.');
-
- const { wrapper, onComplete } = mountComponent();
- await clickApply(wrapper);
-
- expect(getSummaryVm(wrapper).showApplyResultDialog).toBe(true);
- expect(wrapper.text()).toContain('Setup Saved in Best-Effort Mode');
- expect(onComplete).not.toHaveBeenCalled();
-
- await clickButtonByText(wrapper, 'OK');
-
- expect(onComplete).toHaveBeenCalledTimes(1);
- });
-
it('continues and classifies warnings when server identity mutation rejects invalid input', async () => {
draftStore.serverName = 'bad name!';
updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters'));
diff --git a/web/__test__/store/onboardingModalVisibility.test.ts b/web/__test__/store/onboardingModalVisibility.test.ts
index 052a040f3d..a78ff72d88 100644
--- a/web/__test__/store/onboardingModalVisibility.test.ts
+++ b/web/__test__/store/onboardingModalVisibility.test.ts
@@ -156,6 +156,7 @@ describe('OnboardingModalVisibility Store', () => {
await expect(store.closeModal()).resolves.toBe(true);
expect(closeMutationMock).toHaveBeenCalledTimes(1);
+ expect(closeMutationMock).toHaveBeenCalledWith();
expect(refetchOnboardingMock).toHaveBeenCalledTimes(1);
expect(store.isVisible).toBe(false);
});
diff --git a/web/components.d.ts b/web/components.d.ts
index 824d47c6a0..c0804a1a92 100644
--- a/web/components.d.ts
+++ b/web/components.d.ts
@@ -110,6 +110,7 @@ declare module 'vue' {
OnboardingPartnerLogo: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogo.vue')['default']
OnboardingPartnerLogoImg: typeof import('./src/components/Onboarding/components/OnboardingPartnerLogoImg.vue')['default']
OnboardingPluginsStep: typeof import('./src/components/Onboarding/steps/OnboardingPluginsStep.vue')['default']
+ OnboardingStepQueryGate: typeof import('./src/components/Onboarding/components/OnboardingStepQueryGate.vue')['default']
OnboardingSteps: typeof import('./src/components/Onboarding/OnboardingSteps.vue')['default']
OnboardingSummaryStep: typeof import('./src/components/Onboarding/steps/OnboardingSummaryStep.vue')['default']
Overview: typeof import('./src/components/Docker/Overview.vue')['default']
diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue
index efc3e0f132..c7353722bb 100644
--- a/web/src/components/Onboarding/OnboardingModal.vue
+++ b/web/src/components/Onboarding/OnboardingModal.vue
@@ -286,9 +286,9 @@ const docsButtons = computed(() => {
];
});
-const closeModal = async (reason?: 'SAVE_FAILURE') => {
+const closeModal = async () => {
try {
- await onboardingModalStore.closeModal(reason);
+ await onboardingModalStore.closeModal();
} finally {
cleanupOnboardingStorage();
clearHistorySession();
@@ -542,19 +542,6 @@ const handleExitConfirm = async () => {
}
};
-const handleSaveFailureClose = async () => {
- if (isClosingModal.value) {
- return;
- }
-
- isClosingModal.value = true;
- try {
- await closeModal('SAVE_FAILURE');
- } finally {
- isClosingModal.value = false;
- }
-};
-
const handleModalVisibilityUpdate = async (value: boolean) => {
if (!value) {
handleExitIntent();
@@ -708,6 +695,7 @@ const currentStepProps = computed>(() => {
onSkipSetup: handleExitIntent,
onSkip: undefined,
showSkip: false,
+ saveError: saveTransitionError.value,
};
case 'CONFIGURE_SETTINGS': {
@@ -717,6 +705,7 @@ const currentStepProps = computed>(() => {
initialDraft: localDraft.value.coreSettings ?? null,
onComplete: handleCoreSettingsComplete,
onBack: handleCoreSettingsBack,
+ onCloseOnboarding: handleExitIntent,
showSkip: !hardcodedStep?.required,
saveError: saveTransitionError.value,
};
@@ -730,6 +719,7 @@ const currentStepProps = computed>(() => {
onComplete: handlePluginsComplete,
onSkip: hardcodedStep?.required ? undefined : handlePluginsSkip,
onBack: handlePluginsBack,
+ onCloseOnboarding: handleExitIntent,
showSkip: !hardcodedStep?.required,
isRequired: hardcodedStep?.required ?? false,
saveError: saveTransitionError.value,
@@ -744,6 +734,7 @@ const currentStepProps = computed>(() => {
onComplete: handleInternalBootComplete,
onSkip: hardcodedStep?.required ? undefined : handleInternalBootSkip,
onBack: handleInternalBootBack,
+ onCloseOnboarding: handleExitIntent,
showSkip: !hardcodedStep?.required,
saveError: saveTransitionError.value,
};
@@ -763,6 +754,7 @@ const currentStepProps = computed>(() => {
allowSkip: allowActivationSkip.value,
showKeyfileHint: showKeyfileHint.value,
showActivationCodeHint: hasActivationCode.value,
+ saveError: saveTransitionError.value,
};
case 'NEXT_STEPS':
@@ -781,6 +773,8 @@ const currentStepProps = computed>(() => {
onInternalBootStateChange: handleInternalBootStateChange,
onComplete: handleSummaryComplete,
onBack: handleSummaryBack,
+ onCloseOnboarding: handleExitIntent,
+ saveError: saveTransitionError.value,
};
default:
@@ -825,25 +819,6 @@ const currentStepProps = computed>(() => {
class="mb-8"
/>
-
-
-
- {{ saveTransitionError }}
-
-
- {{ t('onboarding.modal.exit.confirm') }}
-
-
-
-
+import { computed } from 'vue';
+import { useI18n } from 'vue-i18n';
+
+import { ExclamationTriangleIcon } from '@heroicons/vue/24/solid';
+import { BrandButton } from '@unraid/ui';
+import OnboardingLoadingState from '@/components/Onboarding/components/OnboardingLoadingState.vue';
+
+const props = withDefaults(
+ defineProps<{
+ loading?: boolean;
+ error?: unknown | null;
+ loadingTitle?: string;
+ loadingDescription?: string;
+ errorTitle?: string;
+ errorDescription?: string;
+ onRetry?: () => void | Promise;
+ onCloseOnboarding?: () => void | Promise;
+ }>(),
+ {
+ loading: false,
+ loadingTitle: '',
+ loadingDescription: '',
+ errorTitle: '',
+ errorDescription: '',
+ onRetry: undefined,
+ onCloseOnboarding: undefined,
+ }
+);
+
+const { t } = useI18n();
+
+const hasError = computed(() => Boolean(props.error));
+const resolvedLoadingTitle = computed(() => props.loadingTitle || t('onboarding.loading.title'));
+const resolvedErrorTitle = computed(() => props.errorTitle || t('onboarding.stepQueryGate.errorTitle'));
+const resolvedErrorDescription = computed(
+ () => props.errorDescription || t('onboarding.stepQueryGate.errorDescription')
+);
+
+
+