From 33e218a9f7e72967105b50d9dda3580d7714874b Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 27 Feb 2026 12:01:05 -0500 Subject: [PATCH 01/53] feat(onboarding): add internal boot step to flow - Purpose: introduce a dedicated onboarding step for internal boot setup using existing webgui behavior and endpoints. - Before: onboarding had no internal boot step, so users had to leave onboarding and configure it elsewhere. - Problem: internal boot setup was disconnected from first-run setup and could not be intentionally skipped/recorded in onboarding state. - Change: added an INTERNAL_BOOT step after core settings, hidden for partner builds, with skip support and persisted draft state. - Implementation: added internal boot template parser/submission helper, new step UI with validation (slots/devices/size/update BIOS), and modal/step registry wiring. --- .../components/Onboarding/OnboardingModal.vue | 41 +- .../components/Onboarding/OnboardingSteps.vue | 2 + .../Onboarding/composables/internalBoot.ts | 205 +++++++ web/src/components/Onboarding/stepRegistry.ts | 7 + .../steps/OnboardingInternalBootStep.vue | 499 ++++++++++++++++++ .../Onboarding/store/onboardingDraft.ts | 85 +++ 6 files changed, 826 insertions(+), 13 deletions(-) create mode 100644 web/src/components/Onboarding/composables/internalBoot.ts create mode 100644 web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue diff --git a/web/src/components/Onboarding/OnboardingModal.vue b/web/src/components/Onboarding/OnboardingModal.vue index 76bbaddef9..47333eca11 100644 --- a/web/src/components/Onboarding/OnboardingModal.vue +++ b/web/src/components/Onboarding/OnboardingModal.vue @@ -31,7 +31,7 @@ const { activationRequired, hasActivationCode, registrationState } = storeToRefs useActivationCodeDataStore() ); const onboardingStore = useUpgradeOnboardingStore(); -const { shouldShowOnboarding, isVersionDrift, completedAtVersion, canDisplayOnboardingModal } = +const { shouldShowOnboarding, isVersionDrift, completedAtVersion, canDisplayOnboardingModal, isPartnerBuild } = storeToRefs(onboardingStore); const { refetchOnboarding } = onboardingStore; const purchaseStore = usePurchaseStore(); @@ -61,6 +61,7 @@ const activateExternal = computed(() => purchaseStore.openInNewTab); type StepId = | 'OVERVIEW' | 'CONFIGURE_SETTINGS' + | 'INTERNAL_BOOT' | 'ADD_PLUGINS' | 'ACTIVATE_LICENSE' | 'SUMMARY' @@ -70,6 +71,7 @@ type StepId = const HARDCODED_STEPS: Array<{ id: StepId; required: boolean }> = [ { id: 'OVERVIEW', required: false }, { id: 'CONFIGURE_SETTINGS', required: false }, + { id: 'INTERNAL_BOOT', required: false }, { id: 'ADD_PLUGINS', required: false }, { id: 'ACTIVATE_LICENSE', required: true }, { id: 'SUMMARY', required: false }, @@ -83,20 +85,15 @@ const showActivationStep = computed(() => { }); // Determine which steps to show based on user state -const availableSteps = computed(() => { - if (showActivationStep.value) { - return HARDCODED_STEPS.map((s) => s.id); - } - return HARDCODED_STEPS.filter((s) => s.id !== 'ACTIVATE_LICENSE').map((s) => s.id); -}); +const visibleHardcodedSteps = computed(() => + HARDCODED_STEPS.filter((step) => showActivationStep.value || step.id !== 'ACTIVATE_LICENSE').filter( + (step) => !isPartnerBuild.value || step.id !== 'INTERNAL_BOOT' + ) +); +const availableSteps = computed(() => visibleHardcodedSteps.value.map((step) => step.id)); // Filtered steps as full objects for OnboardingSteps component -const filteredSteps = computed(() => { - if (showActivationStep.value) { - return HARDCODED_STEPS; - } - return HARDCODED_STEPS.filter((s) => s.id !== 'ACTIVATE_LICENSE'); -}); +const filteredSteps = computed(() => visibleHardcodedSteps.value); const isLoginPage = computed(() => { const hasLoginRoute = window.location.pathname.includes('login'); @@ -243,6 +240,14 @@ const handlePluginsSkip = async () => { await goToNextStep(); }; +const handleInternalBootComplete = async () => { + await goToNextStep(); +}; + +const handleInternalBootSkip = async () => { + await goToNextStep(); +}; + const handleExitIntent = () => { showExitConfirmDialog.value = true; }; @@ -311,6 +316,16 @@ const currentStepProps = computed>(() => { }; } + case 'INTERNAL_BOOT': { + const hardcodedStep = HARDCODED_STEPS.find((s) => s.id === 'INTERNAL_BOOT'); + return { + ...baseProps, + onComplete: handleInternalBootComplete, + onSkip: hardcodedStep?.required ? undefined : handleInternalBootSkip, + showSkip: !hardcodedStep?.required, + }; + } + case 'ACTIVATE_LICENSE': return { ...baseProps, diff --git a/web/src/components/Onboarding/OnboardingSteps.vue b/web/src/components/Onboarding/OnboardingSteps.vue index 46a24bed27..6798cf4f3f 100644 --- a/web/src/components/Onboarding/OnboardingSteps.vue +++ b/web/src/components/Onboarding/OnboardingSteps.vue @@ -10,6 +10,7 @@ import { stepMetadata } from '~/components/Onboarding/stepRegistry'; type StepId = | 'OVERVIEW' | 'CONFIGURE_SETTINGS' + | 'INTERNAL_BOOT' | 'ADD_PLUGINS' | 'ACTIVATE_LICENSE' | 'SUMMARY' @@ -54,6 +55,7 @@ const dynamicSteps = computed(() => { const defaultSteps = [ metadataLookup.OVERVIEW, metadataLookup.CONFIGURE_SETTINGS, + metadataLookup.INTERNAL_BOOT, metadataLookup.ADD_PLUGINS, metadataLookup.ACTIVATE_LICENSE, ]; diff --git a/web/src/components/Onboarding/composables/internalBoot.ts b/web/src/components/Onboarding/composables/internalBoot.ts new file mode 100644 index 0000000000..24d14a981b --- /dev/null +++ b/web/src/components/Onboarding/composables/internalBoot.ts @@ -0,0 +1,205 @@ +export interface InternalBootDeviceOption { + value: string; + label: string; + sizeMiB: number | null; +} + +export interface InternalBootTemplateData { + isBootPoolUiAvailable: boolean; + isBootPoolEligible: boolean; + poolNameDefault: string; + slotOptions: number[]; + deviceOptions: InternalBootDeviceOption[]; + bootSizePresetsMiB: number[]; + defaultBootSizeMiB: number; + defaultUpdateBios: boolean; +} + +export interface InternalBootSelection { + poolName: string; + devices: string[]; + bootSizeMiB: number; + updateBios: boolean; +} + +export interface SubmitInternalBootOptions { + reboot?: boolean; +} + +export interface InternalBootSubmitResult { + ok: boolean; + code?: number; + output: string; +} + +const DEFAULT_BOOT_SIZE_MIB = 16384; + +const parseInteger = (value: string | null | undefined): number | null => { + if (!value) { + return null; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; +}; + +const parsePositiveInteger = (value: string | null | undefined): number | null => { + const parsed = parseInteger(value); + if (parsed === null || parsed <= 0) { + return null; + } + return parsed; +}; + +const parseTemplateData = (html: string): InternalBootTemplateData => { + const parser = new DOMParser(); + const documentNode = parser.parseFromString(html, 'text/html'); + const templateRoot = documentNode.querySelector('#templatePopupBootPool'); + + if (!templateRoot) { + return { + isBootPoolUiAvailable: false, + isBootPoolEligible: false, + poolNameDefault: 'cache', + slotOptions: [1, 2], + deviceOptions: [], + bootSizePresetsMiB: [16384, 32768, 65536, 131072], + defaultBootSizeMiB: DEFAULT_BOOT_SIZE_MIB, + defaultUpdateBios: true, + }; + } + + const slotOptions = Array.from(templateRoot.querySelectorAll('select[name="poolSlots"] option')) + .map((option) => parsePositiveInteger(option.getAttribute('value'))) + .filter((value): value is number => value !== null); + + const deviceOptions = Array.from( + templateRoot.querySelectorAll('#bootPoolDevicesSource select option') + ) + .map((option) => { + const value = option.getAttribute('value')?.trim() ?? ''; + if (!value) { + return null; + } + return { + value, + label: option.textContent?.trim() ?? value, + sizeMiB: parsePositiveInteger(option.getAttribute('data-size-mib')), + }; + }) + .filter((option): option is InternalBootDeviceOption => option !== null); + + const bootSizePresetsMiB = Array.from( + templateRoot.querySelectorAll('select[name="poolBootSizePreset"] option') + ) + .map((option) => parseInteger(option.getAttribute('value'))) + .filter((value): value is number => value !== null && value >= 0); + + const defaultBootSizeMiB = + parsePositiveInteger( + templateRoot.querySelector('input[name="poolBootSize"]')?.getAttribute('value') + ) ?? bootSizePresetsMiB[0] ?? DEFAULT_BOOT_SIZE_MIB; + + const poolNameDefault = + templateRoot.querySelector('input[name="poolName"]')?.value?.trim() || 'cache'; + const defaultUpdateBios = Boolean( + templateRoot.querySelector('input[name="poolUpdateBios"]')?.checked + ); + const isBootPoolEligible = Boolean( + documentNode.querySelector('input[onclick="addBootPoolPopup()"]') + ); + + return { + isBootPoolUiAvailable: true, + isBootPoolEligible, + poolNameDefault, + slotOptions: slotOptions.length > 0 ? slotOptions : [1, 2], + deviceOptions, + bootSizePresetsMiB: + bootSizePresetsMiB.length > 0 ? bootSizePresetsMiB : [16384, 32768, 65536, 131072], + defaultBootSizeMiB, + defaultUpdateBios, + }; +}; + +export const fetchInternalBootTemplateData = async (): Promise => { + const response = await fetch('/Main/PoolDevices', { + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error(`Failed to load pool devices page (${response.status})`); + } + + const html = await response.text(); + return parseTemplateData(html); +}; + +const buildInternalBootArgs = ( + selection: InternalBootSelection, + options: SubmitInternalBootOptions +): string[] => { + const args = [selection.poolName, String(selection.bootSizeMiB), ...selection.devices]; + if (selection.updateBios) { + args.push('updatebios'); + } + if (options.reboot) { + args.push('reboot'); + } + args.push('update'); + return args; +}; + +export const submitInternalBootCreation = async ( + selection: InternalBootSelection, + options: SubmitInternalBootOptions = {} +): Promise => { + const payload = new URLSearchParams(); + const args = buildInternalBootArgs(selection, options); + for (const arg of args) { + payload.append('args[]', arg); + } + + const response = await fetch('/plugins/dynamix/include/mkbootpool.php', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: payload.toString(), + }); + + const raw = await response.text(); + + try { + const parsed = JSON.parse(raw) as { + ok?: boolean; + code?: number; + output?: string; + }; + return { + ok: Boolean(parsed.ok), + code: parsed.code, + output: parsed.output ?? raw, + }; + } catch { + return { + ok: false, + output: raw || `mkbootpool request failed (${response.status})`, + }; + } +}; + +export const submitInternalBootReboot = () => { + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/webGui/include/Boot.php'; + form.className = 'hidden'; + + const cmd = document.createElement('input'); + cmd.type = 'hidden'; + cmd.name = 'cmd'; + cmd.value = 'reboot'; + form.appendChild(cmd); + + document.body.appendChild(form); + form.submit(); +}; diff --git a/web/src/components/Onboarding/stepRegistry.ts b/web/src/components/Onboarding/stepRegistry.ts index 07daee9e2c..ebc82a6040 100644 --- a/web/src/components/Onboarding/stepRegistry.ts +++ b/web/src/components/Onboarding/stepRegistry.ts @@ -1,6 +1,7 @@ import type { Component } from 'vue'; import OnboardingCoreSettingsStep from '~/components/Onboarding/steps/OnboardingCoreSettingsStep.vue'; +import OnboardingInternalBootStep from '~/components/Onboarding/steps/OnboardingInternalBootStep.vue'; import OnboardingLicenseStep from '~/components/Onboarding/steps/OnboardingLicenseStep.vue'; import OnboardingNextStepsStep from '~/components/Onboarding/steps/OnboardingNextStepsStep.vue'; import OnboardingOverviewStep from '~/components/Onboarding/steps/OnboardingOverviewStep.vue'; @@ -10,6 +11,7 @@ import OnboardingSummaryStep from '~/components/Onboarding/steps/OnboardingSumma export const stepComponents: Record = { OVERVIEW: OnboardingOverviewStep, CONFIGURE_SETTINGS: OnboardingCoreSettingsStep, + INTERNAL_BOOT: OnboardingInternalBootStep, ADD_PLUGINS: OnboardingPluginsStep, ACTIVATE_LICENSE: OnboardingLicenseStep, SUMMARY: OnboardingSummaryStep, @@ -33,6 +35,11 @@ export const stepMetadata: Record = { descriptionKey: 'onboarding.coreSettings.description', icon: 'i-heroicons-cog-6-tooth', }, + INTERNAL_BOOT: { + titleKey: 'Internal Boot', + descriptionKey: 'Configure bootable pool', + icon: 'i-heroicons-circle-stack', + }, ADD_PLUGINS: { titleKey: 'onboarding.pluginsStep.installEssentialPlugins', descriptionKey: 'onboarding.pluginsStep.addHelpfulPlugins', diff --git a/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue new file mode 100644 index 0000000000..de85a6f6f4 --- /dev/null +++ b/web/src/components/Onboarding/steps/OnboardingInternalBootStep.vue @@ -0,0 +1,499 @@ + + + diff --git a/web/src/components/Onboarding/store/onboardingDraft.ts b/web/src/components/Onboarding/store/onboardingDraft.ts index dcee6df950..009323efff 100644 --- a/web/src/components/Onboarding/store/onboardingDraft.ts +++ b/web/src/components/Onboarding/store/onboardingDraft.ts @@ -1,6 +1,14 @@ import { ref } from 'vue'; import { defineStore } from 'pinia'; +export interface OnboardingInternalBootSelection { + poolName: string; + slotCount: number; + devices: string[]; + bootSizeMiB: number; + updateBios: boolean; +} + const normalizePersistedPlugins = (value: unknown): string[] => { if (Array.isArray(value)) { return value.filter((item): item is string => typeof item === 'string'); @@ -13,6 +21,39 @@ const normalizePersistedPlugins = (value: unknown): string[] => { return []; }; +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; + }; + + 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 = Number.isFinite(parsedBootSize) && parsedBootSize > 0 ? parsedBootSize : 16384; + + return { + poolName, + slotCount, + devices, + bootSizeMiB, + updateBios: Boolean(candidate.updateBios), + }; +}; + export const useOnboardingDraftStore = defineStore( 'onboardingDraft', () => { @@ -29,6 +70,12 @@ export const useOnboardingDraftStore = defineStore( const selectedPlugins = ref>(new Set()); const pluginSelectionInitialized = ref(false); + // Internal boot + const internalBootSelection = ref(null); + const internalBootInitialized = ref(false); + const internalBootSkipped = ref(false); + const internalBootApplySucceeded = ref(false); + // Navigation const currentStepIndex = ref(0); @@ -55,6 +102,30 @@ export const useOnboardingDraftStore = defineStore( pluginSelectionInitialized.value = true; } + function setInternalBootSelection(selection: OnboardingInternalBootSelection) { + internalBootSelection.value = { + poolName: selection.poolName, + slotCount: selection.slotCount, + devices: [...selection.devices], + bootSizeMiB: selection.bootSizeMiB, + updateBios: selection.updateBios, + }; + internalBootInitialized.value = true; + internalBootSkipped.value = false; + internalBootApplySucceeded.value = false; + } + + function skipInternalBoot() { + internalBootSelection.value = null; + internalBootInitialized.value = true; + internalBootSkipped.value = true; + internalBootApplySucceeded.value = false; + } + + function setInternalBootApplySucceeded(value: boolean) { + internalBootApplySucceeded.value = value; + } + function setStepIndex(index: number) { currentStepIndex.value = index; } @@ -69,9 +140,16 @@ export const useOnboardingDraftStore = defineStore( coreSettingsInitialized, selectedPlugins, pluginSelectionInitialized, + internalBootSelection, + internalBootInitialized, + internalBootSkipped, + internalBootApplySucceeded, currentStepIndex, setCoreSettings, setPlugins, + setInternalBootSelection, + skipInternalBoot, + setInternalBootApplySucceeded, setStepIndex, }; }, @@ -85,6 +163,9 @@ export const useOnboardingDraftStore = defineStore( }, deserialize: (value) => { const parsed = JSON.parse(value) as Record; + const normalizedInternalBootSelection = normalizePersistedInternalBootSelection( + parsed.internalBootSelection + ); const hasLegacyCoreDraft = (typeof parsed.serverName === 'string' && parsed.serverName.length > 0) || (typeof parsed.serverDescription === 'string' && parsed.serverDescription.length > 0) || @@ -99,6 +180,10 @@ export const useOnboardingDraftStore = defineStore( return { ...parsed, selectedPlugins: new Set(normalizePersistedPlugins(parsed.selectedPlugins)), + internalBootSelection: normalizedInternalBootSelection, + internalBootInitialized: Boolean(parsed.internalBootInitialized), + internalBootSkipped: Boolean(parsed.internalBootSkipped), + internalBootApplySucceeded: Boolean(parsed.internalBootApplySucceeded), coreSettingsInitialized: Boolean(parsed.coreSettingsInitialized || hasLegacyCoreDraft), pluginSelectionInitialized: hadLegacyPluginShape ? false From 1ceb91aef0bb54e3314089119de305ec5008702f Mon Sep 17 00:00:00 2001 From: Ajit Mehrotra Date: Fri, 27 Feb 2026 12:01:18 -0500 Subject: [PATCH 02/53] feat(onboarding): apply internal boot setup from summary - Purpose: execute internal boot configuration during onboarding confirm/apply and surface reboot follow-up. - Before: summary only applied core settings/plugins/ssh; internal boot selections were not applied. - Problem: users could choose internal boot but onboarding would not run mkbootpool or reflect reboot needs. - Change: summary now calls mkbootpool endpoint without reboot, logs command output, and treats failures as warnings in best-effort flow. - Implementation: added internal boot summary card, included internal boot in apply-change detection, and switched final CTA to reboot when internal boot apply succeeds. --- .../steps/OnboardingNextStepsStep.vue | 20 ++- .../steps/OnboardingSummaryStep.vue | 115 +++++++++++++++++- 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue index 998d6d2276..78f886f88e 100644 --- a/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingNextStepsStep.vue @@ -16,7 +16,9 @@ import { CheckCircleIcon, EnvelopeIcon } from '@heroicons/vue/24/solid'; import { BrandButton } from '@unraid/ui'; // Use ?raw to import SVG content string import UnraidIconSvg from '@/assets/partners/simple-icons-unraid.svg?raw'; +import { submitInternalBootReboot } from '@/components/Onboarding/composables/internalBoot'; import { useActivationCodeDataStore } from '@/components/Onboarding/store/activationCodeData'; +import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft'; export interface Props { onComplete: () => void; @@ -27,6 +29,7 @@ export interface Props { const props = defineProps(); const { t } = useI18n(); const store = useActivationCodeDataStore(); +const draftStore = useOnboardingDraftStore(); const partnerInfo = computed(() => store.partnerInfo); const activationCode = computed(() => store.activationCode); @@ -45,6 +48,10 @@ 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.internalBootApplySucceeded); +const primaryButtonText = computed(() => + showRebootButton.value ? 'Reboot' : t('onboarding.nextSteps.continueToDashboard') +); const basicsItems = [ { label: t('onboarding.nextSteps.basics.shares'), url: 'https://docs.unraid.net/go/shares/' }, @@ -72,6 +79,15 @@ const handleMouseMove = (e: MouseEvent) => { el.style.setProperty('--x', `${x}px`); el.style.setProperty('--y', `${y}px`); }; + +const handlePrimaryAction = () => { + if (showRebootButton.value) { + submitInternalBootReboot(); + return; + } + + props.onComplete(); +};