feat(onboarding): force reboot after internal boot setup and lock down wizard (#1966)

## Summary

When internal boot configuration is applied during onboarding, the
system must reboot (or shut down) to finalize setup — regardless of
whether the apply succeeded or failed. Previously, users could escape
the reboot path via the X button, back navigation, browser back, a
keyboard shortcut (`Ctrl+Alt+Shift+O`), or a URL bypass
(`?onboarding=bypass`). This left the system in a potentially broken
state if internal boot was partially configured but the user never
rebooted.

## Changes

### Lockdown mechanism
- Added `internalBootApplyAttempted` flag to the onboarding draft store
(persisted to localStorage)
- Flag is set `true` when the Summary step begins applying internal
boot, **before** the API call — this engages the lockdown immediately
- All escape hatches are gated on this flag via a single
`isInternalBootLocked` computed

### Wizard lockdown (OnboardingModal.vue)
- X close button hidden when locked
- Back button hidden on all steps when locked
- `handleExitIntent`, `goToPreviousStep` — early return when locked
- `goToStep` — blocks backward stepper clicks when locked
- `handlePopstate` — calls `window.history.forward()` to neutralize
browser back, with an `isProgrammaticHistoryExit` guard so the modal's
own `closeModal()` history navigation isn't blocked
- Removed keyboard shortcut `Ctrl+Alt+Shift+O` and URL parameter
`?onboarding=bypass` entirely (feature hasn't shipped)

### Forced reboot/shutdown on success or failure
(OnboardingNextStepsStep.vue)
- `showRebootButton` now checks `internalBootSelection !== null` instead
of `internalBootApplySucceeded` — reboot shows regardless of outcome
- Added **shutdown button** alongside reboot as a secondary option
(smaller, text-style) — mirrors the "Skip Setup" / "Get Started" CTA
pattern
- Shutdown calls the same server shutdown mutation and shows the same
confirmation dialog pattern as reboot
- Added failure alert: *"Internal boot timed out but is likely setup on
your server. Please reboot your system to finalize setup."*
- Added BIOS warning when `updateBios` was selected but apply failed —
instructs user to manually update BIOS boot order
- Made `completeOnboarding()` failure non-blocking for the
reboot/shutdown path — wraps in try/catch so users are never stuck

### Standalone lockdown (OnboardingInternalBoot.standalone.vue)
- Same lockdown: X hidden, popstate blocked, dialog dismiss blocked
- "Edit Again" disabled when locked
- "Close" button becomes **two buttons**: "Shutdown" (secondary) and
"Reboot" (primary) when locked
- BIOS warning shown on failure with `updateBios` selected
- Failure message uses same wording as wizard flow

### Log message preservation (SummaryStep + composable)
- `returnedError` and `failed` callbacks in `applyInternalBootSelection`
preserve the original error output in the user-visible log stream
- The generic "timed out / likely setup" message only appears on the
Next Steps popup — the actual error details remain in the technical logs

### i18n
- 7 new keys in `en.json` for failure messaging, BIOS warnings, shutdown
button, and dialog descriptions

## Test plan

- [x] All tests pass (64 test files, 628+ tests)
- [x] Lint passes (ESLint + Prettier)
- [x] Type-check passes (vue-tsc)
- [ ] Manual: start onboarding with internal boot eligible → select
storage boot → apply → verify lockdown engages
- [ ] Manual: simulate API failure → verify reboot AND shutdown buttons
still show + failure messaging
- [ ] Manual: verify browser back, keyboard shortcut, URL bypass all do
nothing during lockdown
- [ ] Manual: click Shutdown → confirm it calls shutdown mutation, not
reboot
- [ ] Manual: test standalone internal boot wizard with same scenarios
- [ ] Manual: verify log console still shows specific error details on
failure

## Files changed (13)

| File | Change |
|------|--------|
| `store/onboardingDraft.ts` | Added `internalBootApplyAttempted` flag +
setter + persistence |
| `store/onboardingModalVisibility.ts` | Removed bypass shortcut, URL
action, and `bypassOnboarding()` |
| `OnboardingModal.vue` | Added lockdown gates on X, back, popstate
(with programmatic exit guard), stepper, exit intent |
| `composables/internalBoot.ts` | Restored original error output in
`returnedError`/`failed` callbacks |
| `steps/OnboardingSummaryStep.vue` | Set `internalBootApplyAttempted`
before apply |
| `steps/OnboardingNextStepsStep.vue` | Forced reboot on failure,
shutdown button, failure alerts, BIOS warning, resilient
`finishOnboarding` |
| `standalone/OnboardingInternalBoot.standalone.vue` | Same lockdown +
shutdown/reboot buttons + failure messaging |
| `locales/en.json` | 7 new i18n keys |
| 5 test files | Updated/added tests for lockdown, shutdown, and failure
behavior |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added Shutdown alongside Reboot for internal-boot recovery and a
confirmation dialog for power actions.
* Modal/result UI now shows Reboot/Shutdown actions when internal-boot
is locked.

* **Improvements**
* Locked failure state removes “Edit Again” and blocks
close/back/browser-back while internal boot is in progress.
* Internal-boot timeout and BIOS-missed messaging updated with new
localization keys.
  * Onboarding flow tracks an “apply attempted” flag to drive UI state.

* **Removed**
  * Keyboard shortcut bypass for onboarding.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ajit Mehrotra
2026-03-26 20:47:58 -04:00
committed by GitHub
parent abe72837a9
commit 9323b14879
18 changed files with 956 additions and 261 deletions

View File

@@ -1,3 +1,4 @@
import { reactive } from 'vue';
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -21,12 +22,16 @@ type InternalBootHistoryState = {
const {
draftStore,
reactiveStoreRef,
applyInternalBootSelectionMock,
submitInternalBootRebootMock,
submitInternalBootShutdownMock,
cleanupOnboardingStorageMock,
dialogPropsRef,
stepPropsRef,
stepperPropsRef,
} = vi.hoisted(() => {
const reactiveRef: { value: Record<string, unknown> | null } = { value: null };
const store = {
internalBootSelection: null as {
poolName: string;
@@ -34,15 +39,31 @@ const {
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
} | null,
internalBootApplySucceeded: false,
internalBootApplyAttempted: false,
setInternalBootApplySucceeded: vi.fn((value: boolean) => {
store.internalBootApplySucceeded = value;
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<
(
@@ -74,18 +95,37 @@ vi.mock('@unraid/ui', () => ({
},
}));
const reactiveDraftStore = reactive(draftStore);
reactiveStoreRef.value = reactiveDraftStore;
vi.mock('@/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => draftStore,
useOnboardingDraftStore: () => reactiveDraftStore,
}));
vi.mock('@/components/Onboarding/composables/internalBoot', () => ({
applyInternalBootSelection: applyInternalBootSelectionMock,
submitInternalBootReboot: submitInternalBootRebootMock,
submitInternalBootShutdown: submitInternalBootShutdownMock,
}));
vi.mock('@/components/Onboarding/store/onboardingStorageCleanup', () => ({
cleanupOnboardingStorage: cleanupOnboardingStorageMock,
}));
vi.mock('@/components/Onboarding/components/InternalBootConfirmDialog.vue', () => ({
default: {
props: ['open', 'action', 'failed', 'disabled'],
emits: ['confirm', 'cancel'],
template: `
<div v-if="open" data-testid="confirm-dialog-stub">
<span data-testid="confirm-dialog-action">{{ action }}</span>
<button data-testid="confirm-dialog-confirm" @click="$emit('confirm')">Confirm</button>
<button data-testid="confirm-dialog-cancel" @click="$emit('cancel')">Cancel</button>
</div>
`,
},
}));
vi.mock('@/components/Onboarding/components/OnboardingConsole.vue', () => ({
default: {
props: ['logs'],
@@ -182,8 +222,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
vi.clearAllMocks();
window.history.replaceState(null, '', window.location.href);
draftStore.internalBootSelection = null;
draftStore.internalBootApplySucceeded = false;
reactiveDraftStore.internalBootSelection = null;
reactiveDraftStore.internalBootApplySucceeded = false;
reactiveDraftStore.internalBootApplyAttempted = false;
dialogPropsRef.value = null;
stepPropsRef.value = null;
stepperPropsRef.value = null;
@@ -239,12 +280,13 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
it('applies the selected internal boot configuration and records success', async () => {
draftStore.internalBootSelection = {
reactiveDraftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: true,
@@ -270,6 +312,7 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
bootSizeMiB: 16384,
updateBios: true,
slotCount: 1,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
@@ -289,13 +332,14 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
});
it('shows retry affordance when the apply helper returns a failure result', async () => {
draftStore.internalBootSelection = {
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',
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: false,
@@ -316,15 +360,8 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
expect(wrapper.text()).toContain('Setup Failed');
expect(wrapper.text()).toContain('Internal boot setup returned an error: mkbootpool failed');
expect(wrapper.find('[data-testid="internal-boot-standalone-edit-again"]').exists()).toBe(true);
await wrapper.get('[data-testid="internal-boot-standalone-edit-again"]').trigger('click');
await flushPromises();
expect(stepperPropsRef.value).toMatchObject({
activeStepIndex: 0,
});
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
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);
});
it('restores the configure step when browser back leaves a reversible result', async () => {
@@ -355,13 +392,15 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
expect(wrapper.find('[data-testid="internal-boot-step-stub"]').exists()).toBe(true);
});
it('closes when browser back leaves a fully applied result', async () => {
draftStore.internalBootSelection = {
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',
};
const wrapper = mountComponent();
@@ -384,8 +423,9 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
await flushPromises();
expect(cleanupOnboardingStorageMock).toHaveBeenCalledTimes(1);
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(false);
expect(forwardSpy).toHaveBeenCalled();
expect(cleanupOnboardingStorageMock).not.toHaveBeenCalled();
expect(wrapper.find('[data-testid="dialog-stub"]').exists()).toBe(true);
});
it('closes locally after showing a result', async () => {
@@ -435,12 +475,13 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
});
it('shows warning result when apply succeeds with warnings', async () => {
draftStore.internalBootSelection = {
reactiveDraftStore.internalBootSelection = {
poolName: 'boot-pool',
slotCount: 1,
devices: ['sda'],
bootSizeMiB: 512,
updateBios: true,
poolMode: 'hybrid',
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: true,
@@ -478,4 +519,132 @@ describe('OnboardingInternalBoot.standalone.vue', () => {
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',
};
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-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);
});
it('calls submitInternalBootReboot when reboot is confirmed through dialog', 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();
await wrapper.get('[data-testid="internal-boot-standalone-reboot"]').trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="confirm-dialog-stub"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="confirm-dialog-action"]').text()).toBe('reboot');
await wrapper.get('[data-testid="confirm-dialog-confirm"]').trigger('click');
await flushPromises();
expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1);
});
it('shows shutdown button when locked and calls submitInternalBootShutdown after confirmation', 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 flushPromises();
const shutdownButton = wrapper.find('[data-testid="internal-boot-standalone-shutdown"]');
expect(shutdownButton.exists()).toBe(true);
await shutdownButton.trigger('click');
await flushPromises();
expect(wrapper.find('[data-testid="confirm-dialog-stub"]').exists()).toBe(true);
expect(wrapper.get('[data-testid="confirm-dialog-action"]').text()).toBe('shutdown');
await wrapper.get('[data-testid="confirm-dialog-confirm"]').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);
});
});

View File

@@ -15,6 +15,7 @@ type MockInternalBootSelection = {
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
};
type InternalBootVm = {
@@ -308,8 +309,16 @@ describe('OnboardingInternalBootStep', () => {
);
});
it('defaults the storage pool name to cache', async () => {
it('defaults the storage pool name to cache in hybrid mode', async () => {
draftStore.bootMode = 'storage';
draftStore.internalBootSelection = {
poolName: '',
slotCount: 1,
devices: [],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
contextResult.value = buildContext({
assignableDisks: [
{
@@ -332,8 +341,16 @@ describe('OnboardingInternalBootStep', () => {
);
});
it('leaves the pool name blank when cache already exists', async () => {
it('leaves the pool name blank in hybrid mode when cache already exists', async () => {
draftStore.bootMode = 'storage';
draftStore.internalBootSelection = {
poolName: '',
slotCount: 1,
devices: [],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
contextResult.value = buildContext({
poolNames: ['cache'],
assignableDisks: [

View File

@@ -61,6 +61,7 @@ const {
onboardingDraftStore: {
currentStepId: { value: null as StepId | null },
internalBootApplySucceeded: { value: false },
internalBootApplyAttempted: { value: false },
setCurrentStep: vi.fn((stepId: StepId) => {
onboardingDraftStore.currentStepId.value = stepId;
}),
@@ -224,6 +225,7 @@ describe('OnboardingModal.vue', () => {
onboardingStatusStore.isPartnerBuild.value = false;
onboardingDraftStore.currentStepId.value = null;
onboardingDraftStore.internalBootApplySucceeded.value = false;
onboardingDraftStore.internalBootApplyAttempted.value = false;
internalBootVisibilityLoading.value = false;
internalBootVisibilityResult.value = {
bootedFromFlashWithInternalBootSetup: false,
@@ -461,4 +463,30 @@ describe('OnboardingModal.vue', () => {
expect(wrapper.find('[data-testid="dialog"]').exists()).toBe(false);
});
it('hides the X button when internal boot lockdown is active', () => {
onboardingDraftStore.internalBootApplyAttempted.value = true;
const wrapper = mountComponent();
expect(wrapper.find('button[aria-label="Close onboarding"]').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';
const wrapper = mountComponent();
expect(wrapper.find('[data-testid="settings-step-back"]').exists()).toBe(false);
});
it('does not open exit confirmation when locked and X area is somehow triggered', async () => {
onboardingDraftStore.internalBootApplyAttempted.value = true;
const wrapper = mountComponent();
expect(wrapper.find('button[aria-label="Close onboarding"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain('Exit onboarding?');
});
});

View File

@@ -11,6 +11,7 @@ const {
draftStore,
activationCodeDataStore,
submitInternalBootRebootMock,
submitInternalBootShutdownMock,
cleanupOnboardingStorageMock,
completeOnboardingMock,
refetchOnboardingMock,
@@ -18,6 +19,15 @@ const {
} = vi.hoisted(() => ({
draftStore: {
internalBootApplySucceeded: false,
internalBootApplyAttempted: false,
internalBootSelection: null as {
poolName: string;
slotCount: number;
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
} | null,
},
activationCodeDataStore: {
partnerInfo: {
@@ -35,6 +45,7 @@ const {
},
},
submitInternalBootRebootMock: vi.fn(),
submitInternalBootShutdownMock: vi.fn(),
cleanupOnboardingStorageMock: vi.fn(),
completeOnboardingMock: vi.fn().mockResolvedValue({}),
refetchOnboardingMock: vi.fn().mockResolvedValue({}),
@@ -66,6 +77,7 @@ vi.mock('~/components/Onboarding/store/onboardingStatus', () => ({
vi.mock('~/components/Onboarding/composables/internalBoot', () => ({
submitInternalBootReboot: submitInternalBootRebootMock,
submitInternalBootShutdown: submitInternalBootShutdownMock,
}));
vi.mock('~/components/Onboarding/store/onboardingStorageCleanup', () => ({
@@ -86,6 +98,8 @@ describe('OnboardingNextStepsStep', () => {
vi.clearAllMocks();
document.body.innerHTML = '';
draftStore.internalBootApplySucceeded = false;
draftStore.internalBootApplyAttempted = false;
draftStore.internalBootSelection = null;
completeOnboardingMock.mockResolvedValue({});
refetchOnboardingMock.mockResolvedValue({});
useMutationMock.mockImplementation((doc: unknown) => {
@@ -148,6 +162,14 @@ describe('OnboardingNextStepsStep', () => {
});
it('marks onboarding complete through the same path before rebooting', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
draftStore.internalBootApplySucceeded = true;
const { wrapper, onComplete } = mountComponent();
@@ -202,4 +224,166 @@ describe('OnboardingNextStepsStep', () => {
expect(onComplete).not.toHaveBeenCalled();
expect(submitInternalBootRebootMock).not.toHaveBeenCalled();
});
it('shows reboot button when internalBootSelection is non-null but apply did not succeed', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
draftStore.internalBootApplySucceeded = false;
const { wrapper } = mountComponent();
const button = wrapper.find('[data-testid="brand-button"]');
expect(button.text()).toContain('Reboot');
});
it('shows failure alert when internal boot failed', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
draftStore.internalBootApplyAttempted = true;
draftStore.internalBootApplySucceeded = false;
const { wrapper } = mountComponent();
expect(wrapper.text()).toContain('Internal boot timed out');
});
it('shows BIOS warning when internal boot failed and updateBios was requested', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
draftStore.internalBootApplyAttempted = true;
draftStore.internalBootApplySucceeded = false;
const { wrapper } = mountComponent();
expect(wrapper.text()).toContain('BIOS boot order update could not be applied');
});
it('proceeds to reboot even when completeOnboarding throws', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
draftStore.internalBootApplySucceeded = true;
completeOnboardingMock.mockRejectedValueOnce(new Error('offline'));
const { wrapper, onComplete } = mountComponent();
const button = wrapper.find('[data-testid="brand-button"]');
await button.trigger('click');
await flushPromises();
const confirmButton = wrapper
.findAll('button')
.find((candidate) => candidate.text().trim() === 'I Understand');
expect(confirmButton).toBeTruthy();
await confirmButton!.trigger('click');
await flushPromises();
expect(completeOnboardingMock).toHaveBeenCalledTimes(1);
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
expect(submitInternalBootRebootMock).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
});
it('shows shutdown button when internal boot is configured', () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
const { wrapper } = mountComponent();
expect(wrapper.text()).toContain('Shutdown');
});
it('does not show shutdown button when no internal boot selection', () => {
const { wrapper } = mountComponent();
expect(wrapper.text()).not.toContain('Shutdown');
});
it('shuts down the server through confirmation dialog', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
draftStore.internalBootApplySucceeded = true;
const { wrapper, onComplete } = mountComponent();
const shutdownButton = wrapper
.findAll('button')
.find((candidate) => candidate.text().trim() === 'Shutdown');
expect(shutdownButton).toBeTruthy();
await shutdownButton!.trigger('click');
await flushPromises();
expect(wrapper.text()).toContain('Confirm Shutdown');
const confirmButton = wrapper
.findAll('button')
.find((candidate) => candidate.text().trim() === 'I Understand');
expect(confirmButton).toBeTruthy();
await confirmButton!.trigger('click');
await flushPromises();
expect(completeOnboardingMock).toHaveBeenCalledTimes(1);
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1);
expect(submitInternalBootRebootMock).not.toHaveBeenCalled();
expect(onComplete).not.toHaveBeenCalled();
});
it('proceeds to shutdown even when completeOnboarding throws', async () => {
draftStore.internalBootSelection = {
poolName: 'cache',
slotCount: 1,
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
completeOnboardingMock.mockRejectedValueOnce(new Error('offline'));
const { wrapper, onComplete } = mountComponent();
const shutdownButton = wrapper
.findAll('button')
.find((candidate) => candidate.text().trim() === 'Shutdown');
await shutdownButton!.trigger('click');
await flushPromises();
const confirmButton = wrapper
.findAll('button')
.find((candidate) => candidate.text().trim() === 'I Understand');
await confirmButton!.trigger('click');
await flushPromises();
expect(cleanupOnboardingStorageMock).toHaveBeenCalledWith();
expect(submitInternalBootShutdownMock).toHaveBeenCalledTimes(1);
expect(onComplete).not.toHaveBeenCalled();
});
});

View File

@@ -26,6 +26,7 @@ import { createTestI18n } from '../../utils/i18n';
const {
draftStore,
setInternalBootApplySucceededMock,
setInternalBootApplyAttemptedMock,
registrationStateRef,
isFreshInstallRef,
activationCodeRef,
@@ -64,14 +65,19 @@ const {
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
poolMode: 'dedicated' | 'hybrid';
} | null,
internalBootInitialized: true,
internalBootSkipped: false,
internalBootApplySucceeded: false,
internalBootApplyAttempted: false,
},
setInternalBootApplySucceededMock: vi.fn((value: boolean) => {
draftStore.internalBootApplySucceeded = value;
}),
setInternalBootApplyAttemptedMock: vi.fn((value: boolean) => {
draftStore.internalBootApplyAttempted = value;
}),
registrationStateRef: { value: 'ENOKEYFILE' },
isFreshInstallRef: { value: true },
activationCodeRef: { value: null as unknown },
@@ -139,6 +145,7 @@ vi.mock('~/components/Onboarding/store/onboardingDraft', () => ({
useOnboardingDraftStore: () => ({
...draftStore,
setInternalBootApplySucceeded: setInternalBootApplySucceededMock,
setInternalBootApplyAttempted: setInternalBootApplyAttemptedMock,
}),
}));
@@ -1155,6 +1162,7 @@ describe('OnboardingSummaryStep', () => {
devices: ['DISK-A', 'DISK-B'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
const { wrapper } = mountComponent();
@@ -1171,6 +1179,7 @@ describe('OnboardingSummaryStep', () => {
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
const { wrapper } = mountComponent();
@@ -1196,6 +1205,7 @@ describe('OnboardingSummaryStep', () => {
devices: ['DISK-A', 'DISK-B'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
draftStore.internalBootSkipped = false;
applyInternalBootSelectionMock.mockResolvedValue({
@@ -1224,14 +1234,16 @@ describe('OnboardingSummaryStep', () => {
bootSizeMiB: 16384,
updateBios: true,
slotCount: 2,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
returnedError: expect.any(Function),
failed: 'Internal boot setup failed',
failed: expect.any(String),
biosUnverified: expect.any(String),
}
);
expect(setInternalBootApplyAttemptedMock).toHaveBeenCalledWith(true);
expect(setInternalBootApplySucceededMock).toHaveBeenCalledWith(true);
expect(wrapper.text()).toContain('Internal boot pool configured.');
expect(wrapper.text()).toContain('BIOS boot entry updates completed successfully.');
@@ -1247,6 +1259,7 @@ describe('OnboardingSummaryStep', () => {
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: false,
@@ -1277,6 +1290,7 @@ describe('OnboardingSummaryStep', () => {
devices: ['DISK-A'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
};
applyInternalBootSelectionMock.mockResolvedValue({
applySucceeded: true,

View File

@@ -43,6 +43,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result).toEqual({
@@ -92,6 +93,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result.ok).toBe(false);
@@ -106,6 +108,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
});
expect(result.ok).toBe(false);
@@ -132,6 +135,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{ reboot: true }
);
@@ -192,6 +196,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
@@ -239,6 +244,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: true,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
@@ -282,6 +288,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
@@ -311,6 +318,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',
@@ -347,6 +355,7 @@ describe('internalBoot composable', () => {
devices: ['disk-1'],
bootSizeMiB: 16384,
updateBios: false,
poolMode: 'hybrid',
},
{
configured: 'Internal boot pool configured.',

View File

@@ -8,11 +8,9 @@ import type { OperationVariables } from '@apollo/client/core';
import type { UseMutationReturn } from '@vue/apollo-composable';
import type { App } from 'vue';
import { BYPASS_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/bypassOnboarding.mutation';
import { CLOSE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/closeOnboarding.mutation';
import { OPEN_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/openOnboarding.mutation';
import { RESUME_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/resumeOnboarding.mutation';
import { useOnboardingDraftStore } from '~/components/Onboarding/store/onboardingDraft';
import { useOnboardingModalStore } from '~/components/Onboarding/store/onboardingModalVisibility';
import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus.js';
import { useCallbackActionsStore } from '~/store/callbackActions';
@@ -39,7 +37,6 @@ describe('OnboardingModalVisibility Store', () => {
const openMutationMock = vi.fn();
const closeMutationMock = vi.fn();
const bypassMutationMock = vi.fn();
const resumeMutationMock = vi.fn();
const refetchOnboardingMock = vi.fn();
@@ -100,14 +97,6 @@ describe('OnboardingModalVisibility Store', () => {
);
}
if (document === BYPASS_ONBOARDING_MUTATION) {
return createMutationReturn(
bypassMutationMock.mockImplementation(async () => {
mockShouldOpen.value = false;
})
);
}
if (document === RESUME_ONBOARDING_MUTATION) {
return createMutationReturn(
resumeMutationMock.mockImplementation(async () => {
@@ -179,37 +168,6 @@ describe('OnboardingModalVisibility Store', () => {
expect(refetchOnboardingMock).not.toHaveBeenCalled();
});
it('applies keyboard shortcut bypass through the backend mutation', () => {
const draftStore = useOnboardingDraftStore();
draftStore.setCoreSettings({
serverName: 'tower',
serverDescription: 'resume me',
timeZone: 'UTC',
theme: 'black',
language: 'en_US',
useSsh: true,
});
draftStore.setCurrentStep('CONFIGURE_BOOT');
mockShouldOpen.value = true;
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'o',
code: 'KeyO',
ctrlKey: true,
altKey: true,
shiftKey: true,
})
);
return vi.waitFor(() => {
expect(bypassMutationMock).toHaveBeenCalledTimes(1);
expect(refetchOnboardingMock).toHaveBeenCalledTimes(1);
expect(store.isVisible).toBe(false);
expect(draftStore.hasResumableDraft).toBe(false);
});
});
it('opens onboarding through the backend when ?onboarding=open is present', async () => {
if (app) {
app.unmount();

1
web/components.d.ts vendored
View File

@@ -78,6 +78,7 @@ declare module 'vue' {
'HeaderOsVersion.standalone': typeof import('./src/components/HeaderOsVersion.standalone.vue')['default']
IgnoredRelease: typeof import('./src/components/UpdateOs/IgnoredRelease.vue')['default']
Indicator: typeof import('./src/components/Notifications/Indicator.vue')['default']
InternalBootConfirmDialog: typeof import('./src/components/Onboarding/components/InternalBootConfirmDialog.vue')['default']
Item: typeof import('./src/components/Notifications/Item.vue')['default']
KeyActions: typeof import('./src/components/KeyActions.vue')['default']
Keyline: typeof import('./src/components/UserProfile/Keyline.vue')['default']

View File

@@ -51,7 +51,9 @@ const { internalBootVisibility, loading: onboardingContextLoading } = storeToRef
useOnboardingContextDataStore()
);
const draftStore = useOnboardingDraftStore();
const { currentStepId, internalBootApplySucceeded } = storeToRefs(draftStore);
const { currentStepId, internalBootApplySucceeded, internalBootApplyAttempted } =
storeToRefs(draftStore);
const isInternalBootLocked = computed(() => internalBootApplyAttempted.value);
onMounted(async () => {
try {
@@ -303,8 +305,11 @@ const docsButtons = computed<BrandButtonProps[]>(() => {
];
});
const isProgrammaticHistoryExit = ref(false);
const closeModal = async (options?: { reload?: boolean }) => {
if (typeof window !== 'undefined' && isManualSession.value && historyPosition.value >= 0) {
isProgrammaticHistoryExit.value = true;
window.history.go(-(historyPosition.value + 1));
return;
}
@@ -345,6 +350,9 @@ const goToNextStep = async () => {
};
const goToPreviousStep = () => {
if (isInternalBootLocked.value) {
return;
}
if (typeof window !== 'undefined' && historySessionId.value && historyPosition.value > 0) {
window.history.back();
return;
@@ -356,6 +364,9 @@ const goToPreviousStep = () => {
};
const goToStep = (stepIndex: number) => {
if (isInternalBootLocked.value && stepIndex < currentDynamicStepIndex.value) {
return;
}
// Prevent skipping ahead via stepper; only allow current or previous steps.
if (
stepIndex >= 0 &&
@@ -414,7 +425,7 @@ const handleInternalBootSkip = async () => {
};
const handleExitIntent = () => {
if (isClosingModal.value) {
if (isClosingModal.value || isInternalBootLocked.value) {
return;
}
showExitConfirmDialog.value = true;
@@ -447,6 +458,12 @@ const handleActivationSkip = async () => {
};
const handlePopstate = async (event: PopStateEvent) => {
if (isInternalBootLocked.value && !isProgrammaticHistoryExit.value) {
window.history.forward();
return;
}
isProgrammaticHistoryExit.value = false;
const nextHistoryState = getHistoryState(event.state);
const activeSessionId = historySessionId.value;
@@ -542,7 +559,7 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
const baseProps = {
onComplete: () => goToNextStep(),
onBack: goToPreviousStep,
showBack: canGoBack.value,
showBack: canGoBack.value && !isInternalBootLocked.value,
isCompleted: false, // No server-side step completion tracking
isSavingStep: false,
};
@@ -634,6 +651,7 @@ const currentStepProps = computed<Record<string, unknown>>(() => {
>
<div class="relative flex h-full min-h-0 w-full flex-col items-center justify-start overflow-y-auto">
<button
v-if="!isInternalBootLocked"
type="button"
class="bg-background/90 text-foreground hover:bg-muted fixed top-5 right-8 z-20 rounded-md p-1.5 shadow-sm transition-colors"
:aria-label="t('onboarding.modal.closeAriaLabel')"

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
open: boolean;
action: 'reboot' | 'shutdown';
disabled?: boolean;
}>();
const emit = defineEmits<{
confirm: [];
cancel: [];
}>();
const { t } = useI18n();
const title = computed(() =>
props.action === 'reboot'
? t('onboarding.nextSteps.confirmReboot.title')
: t('onboarding.nextSteps.confirmShutdown.title')
);
</script>
<template>
<UModal
:open="open"
:portal="false"
:title="title"
:description="t('onboarding.nextSteps.confirmReboot.description')"
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50 max-w-md' }"
@update:open="
(value) => {
if (!value) emit('cancel');
}
"
>
<template #body>
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('onboarding.nextSteps.confirmReboot.warning')"
/>
</template>
<template #footer>
<UButton color="neutral" variant="outline" :disabled="disabled" @click="emit('cancel')">
{{ t('common.cancel') }}
</UButton>
<UButton :disabled="disabled" @click="emit('confirm')">
{{ t('onboarding.nextSteps.confirmReboot.confirm') }}
</UButton>
</template>
</UModal>
</template>

View File

@@ -5,11 +5,14 @@ import { CREATE_INTERNAL_BOOT_POOL_MUTATION } from '@/components/Onboarding/grap
import type { LogEntry } from '@/components/Onboarding/components/OnboardingConsole.vue';
export type PoolMode = 'dedicated' | 'hybrid';
export interface InternalBootSelection {
poolName: string;
devices: string[];
bootSizeMiB: number;
updateBios: boolean;
poolMode: PoolMode;
}
export interface SubmitInternalBootOptions {
@@ -230,7 +233,7 @@ export const applyInternalBootSelection = async (
};
};
export const submitInternalBootReboot = () => {
const submitBootCommand = (command: 'reboot' | 'shutdown') => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/plugins/dynamix/include/Boot.php';
@@ -240,7 +243,7 @@ export const submitInternalBootReboot = () => {
const cmd = document.createElement('input');
cmd.type = 'hidden';
cmd.name = 'cmd';
cmd.value = 'reboot';
cmd.value = command;
form.appendChild(cmd);
const csrfToken = readCsrfToken();
@@ -255,3 +258,6 @@ export const submitInternalBootReboot = () => {
document.body.appendChild(form);
form.submit();
};
export const submitInternalBootReboot = () => submitBootCommand('reboot');
export const submitInternalBootShutdown = () => submitBootCommand('shutdown');

View File

@@ -9,8 +9,13 @@ import {
XMarkIcon,
} from '@heroicons/vue/24/solid';
import { Dialog } from '@unraid/ui';
import InternalBootConfirmDialog from '@/components/Onboarding/components/InternalBootConfirmDialog.vue';
import OnboardingConsole from '@/components/Onboarding/components/OnboardingConsole.vue';
import { applyInternalBootSelection } from '@/components/Onboarding/composables/internalBoot';
import {
applyInternalBootSelection,
submitInternalBootReboot,
submitInternalBootShutdown,
} from '@/components/Onboarding/composables/internalBoot';
import OnboardingSteps from '@/components/Onboarding/OnboardingSteps.vue';
import OnboardingInternalBootStep from '@/components/Onboarding/steps/OnboardingInternalBootStep.vue';
import { useOnboardingDraftStore } from '@/components/Onboarding/store/onboardingDraft';
@@ -48,7 +53,22 @@ const standaloneSteps: Array<{ id: StepId; required: boolean }> = [
const summaryT = (key: string, values?: Record<string, unknown>) =>
t(`onboarding.summaryStep.${key}`, values ?? {});
const isLocked = computed(() => draftStore.internalBootApplyAttempted);
const pendingPowerAction = ref<'reboot' | 'shutdown' | null>(null);
const handleConfirmPowerAction = () => {
const action = pendingPowerAction.value;
pendingPowerAction.value = null;
cleanupOnboardingStorage();
if (action === 'shutdown') {
submitInternalBootShutdown();
} else {
submitInternalBootReboot();
}
};
const canReturnToConfigure = () =>
!isLocked.value &&
confirmationState.value === 'result' &&
(resultSeverity.value !== 'success' || !draftStore.internalBootApplySucceeded);
@@ -146,7 +166,7 @@ const setResultFromApply = (applyResult: InternalBootApplyResult) => {
resultSeverity.value = 'error';
resultTitle.value = summaryT('result.failedTitle');
resultMessage.value = summaryT('result.failedMessage');
resultMessage.value = t('onboarding.nextSteps.internalBootFailed');
};
const closeLocally = () => {
@@ -156,6 +176,9 @@ const closeLocally = () => {
};
const handleClose = (options?: { fromHistory?: boolean }) => {
if (isLocked.value) {
return;
}
if (
typeof window !== 'undefined' &&
!options?.fromHistory &&
@@ -196,6 +219,7 @@ const handleStepComplete = async () => {
return;
}
draftStore.setInternalBootApplyAttempted(true);
confirmationState.value = 'saving';
addLog({
message: summaryT('logs.internalBootStart'),
@@ -234,6 +258,11 @@ const handleStepComplete = async () => {
};
const handlePopstate = (event: PopStateEvent) => {
if (isLocked.value) {
window.history.forward();
return;
}
const nextHistoryState = getHistoryState(event.state) ?? getHistoryState(window.history.state);
const activeSessionId = historySessionId.value;
@@ -328,7 +357,7 @@ onUnmounted(() => {
class="bg-background pb-0"
@update:model-value="
(value) => {
if (!value) {
if (!value && !isLocked) {
handleClose();
}
}
@@ -336,6 +365,7 @@ onUnmounted(() => {
>
<div class="relative flex h-full min-h-0 w-full flex-col items-center justify-start overflow-y-auto">
<button
v-if="!isLocked"
type="button"
data-testid="internal-boot-standalone-close"
class="bg-background/90 text-foreground hover:bg-muted fixed top-5 right-8 z-20 rounded-md p-1.5 shadow-sm transition-colors"
@@ -406,6 +436,17 @@ onUnmounted(() => {
</div>
</div>
<div
v-if="
isLocked && resultSeverity === 'error' && draftStore.internalBootSelection?.updateBios
"
class="mt-4"
>
<p class="text-sm text-amber-700">
{{ t('onboarding.nextSteps.internalBootBiosMissed') }}
</p>
</div>
<div class="mt-6 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<button
v-if="canEditAgain"
@@ -416,7 +457,26 @@ onUnmounted(() => {
>
{{ t('common.back') }}
</button>
<template v-if="isLocked">
<button
type="button"
data-testid="internal-boot-standalone-shutdown"
class="text-muted hover:text-highlighted text-sm font-medium transition-colors"
@click="pendingPowerAction = 'shutdown'"
>
{{ t('onboarding.nextSteps.shutdown') }}
</button>
<button
type="button"
data-testid="internal-boot-standalone-reboot"
class="bg-primary hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-semibold text-white transition-colors"
@click="pendingPowerAction = 'reboot'"
>
{{ t('onboarding.nextSteps.reboot') }}
</button>
</template>
<button
v-else
type="button"
data-testid="internal-boot-standalone-result-close"
class="bg-primary hover:bg-primary/90 rounded-md px-4 py-2 text-sm font-semibold text-white transition-colors"
@@ -431,4 +491,11 @@ onUnmounted(() => {
</div>
</div>
</Dialog>
<InternalBootConfirmDialog
:open="pendingPowerAction !== null"
:action="pendingPowerAction ?? 'reboot'"
@confirm="handleConfirmPowerAction"
@cancel="pendingPowerAction = null"
/>
</template>

View File

@@ -14,6 +14,7 @@ import { convert } from 'convert';
import type {
OnboardingBootMode,
OnboardingInternalBootSelection,
OnboardingPoolMode,
} from '@/components/Onboarding/store/onboardingDraft';
import type { GetInternalBootContextQuery } from '~/composables/gql/graphql';
@@ -146,6 +147,7 @@ const bootMode = ref<OnboardingBootMode>(
toBootMode(draftStore.bootMode ?? (draftStore.internalBootSelection ? 'storage' : 'usb'))
);
const poolMode = ref<OnboardingPoolMode>('dedicated');
const poolName = ref('boot');
const slotCount = ref(1);
const selectedDevices = ref<Array<string | undefined>>([undefined]);
@@ -313,6 +315,19 @@ const canConfigure = computed(
const hasEligibleDevices = computed(() => deviceOptions.value.length > 0);
const hasNoEligibleDevices = computed(() => !hasEligibleDevices.value);
const isStorageBootSelected = computed(() => bootMode.value === 'storage');
const isDedicatedMode = computed(() => poolMode.value === 'dedicated');
const poolModeItems = computed<SelectMenuItem[]>(() => [
{
value: 'dedicated',
label: t('onboarding.internalBootStep.poolMode.dedicated'),
},
{
value: 'hybrid',
label: t('onboarding.internalBootStep.poolMode.hybrid'),
},
]);
const isPrimaryActionDisabled = computed(
() => isStepLocked.value || (isStorageBootSelected.value && (isLoading.value || !canConfigure.value))
);
@@ -514,7 +529,16 @@ watch(
);
watch(
[poolName, slotCount, selectedDevices, bootSizePreset, customBootSizeGb, updateBios, bootMode],
[
poolName,
slotCount,
selectedDevices,
bootSizePreset,
customBootSizeGb,
updateBios,
bootMode,
poolMode,
],
() => {
if (formError.value) {
formError.value = null;
@@ -522,6 +546,14 @@ watch(
}
);
watch(poolMode, (mode) => {
if (mode === 'dedicated') {
poolName.value = 'boot';
} else if (poolName.value === 'boot') {
poolName.value = templateData.value?.poolNameDefault ?? '';
}
});
const isDeviceDisabled = (deviceId: string, index: number) => {
return selectedDevices.value.some(
(selected, selectedIndex) => selectedIndex !== index && selected === deviceId
@@ -540,32 +572,43 @@ const handleUpdateBiosChange = (value: boolean | 'indeterminate') => {
};
const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
const normalizedPoolName = poolName.value.trim();
if (!normalizedPoolName) {
formError.value = t('onboarding.internalBootStep.validation.poolRequired');
return null;
const currentPoolMode = poolMode.value;
const normalizedPoolName = currentPoolMode === 'dedicated' ? 'boot' : poolName.value.trim();
if (currentPoolMode === 'dedicated') {
if (reservedNames.value.has(normalizedPoolName) || existingPoolNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.dedicatedPoolNameConflict');
return null;
}
} else {
if (!normalizedPoolName) {
formError.value = t('onboarding.internalBootStep.validation.poolRequired');
return null;
}
if (reservedNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.poolReserved');
return null;
}
if (shareNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.poolShareName');
return null;
}
}
if (reservedNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.poolReserved');
return null;
}
if (shareNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.poolShareName');
return null;
}
if (existingPoolNames.value.has(normalizedPoolName)) {
if (!isDedicatedMode.value && existingPoolNames.value.has(normalizedPoolName)) {
formError.value = t('onboarding.internalBootStep.validation.poolExists');
return null;
}
const poolNameHasValidChars = /^[a-z][a-z0-9~._-]*$/.test(normalizedPoolName);
const poolNameHasValidEnding = /[a-z_-]$/.test(normalizedPoolName);
if (!poolNameHasValidChars || !poolNameHasValidEnding) {
formError.value = t('onboarding.internalBootStep.validation.poolFormat');
return null;
if (!isDedicatedMode.value) {
const poolNameHasValidChars = /^[a-z][a-z0-9~._-]*$/.test(normalizedPoolName);
const poolNameHasValidEnding = /[a-z_-]$/.test(normalizedPoolName);
if (!poolNameHasValidChars || !poolNameHasValidEnding) {
formError.value = t('onboarding.internalBootStep.validation.poolFormat');
return null;
}
}
if (slotCount.value < 1 || slotCount.value > 2) {
@@ -586,6 +629,17 @@ const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
return null;
}
if (isDedicatedMode.value) {
return {
poolName: normalizedPoolName,
slotCount: slotCount.value,
devices,
bootSizeMiB: 0,
updateBios: updateBios.value,
poolMode: 'dedicated' as const,
};
}
const selectedBootSizeMiB = bootSizeMiB.value;
if (selectedBootSizeMiB === null || !Number.isFinite(selectedBootSizeMiB)) {
formError.value = t('onboarding.internalBootStep.validation.bootSizeRequired');
@@ -606,6 +660,7 @@ const buildValidatedSelection = (): OnboardingInternalBootSelection | null => {
devices,
bootSizeMiB: selectedBootSizeMiB,
updateBios: updateBios.value,
poolMode: 'hybrid' as const,
};
};
@@ -614,7 +669,10 @@ const initializeForm = (data: InternalBootTemplateData) => {
const firstSlot = data.slotOptions[0] ?? 1;
const defaultSlot = Math.max(1, Math.min(2, firstSlot));
poolName.value = draftSelection?.poolName ?? data.poolNameDefault ?? 'cache';
poolMode.value = draftSelection?.poolMode ?? 'dedicated';
poolName.value =
draftSelection?.poolName ||
(poolMode.value === 'dedicated' ? 'boot' : (data.poolNameDefault ?? 'cache'));
slotCount.value = draftSelection?.slotCount ?? defaultSlot;
selectedDevices.value =
draftSelection?.devices.slice(0, slotCount.value) ??
@@ -644,6 +702,7 @@ watch(
return;
}
poolMode.value = selection.poolMode ?? 'hybrid';
poolName.value = selection.poolName;
slotCount.value = selection.slotCount;
selectedDevices.value = [...selection.devices];
@@ -740,7 +799,7 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
/>
<UAlert
v-if="isStorageBootSelected && hasEligibleDevices"
v-if="isStorageBootSelected && hasEligibleDevices && isDedicatedMode"
data-testid="internal-boot-intro-panel"
class="my-8"
color="neutral"
@@ -749,6 +808,32 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
>
<template #description>
<div class="space-y-3 text-sm leading-relaxed">
<p class="font-semibold">
{{ t('onboarding.internalBootStep.warning.dedicatedModeTitle') }}
</p>
<p>{{ t('onboarding.internalBootStep.warning.dedicatedPoolDescription') }}</p>
<p>{{ t('onboarding.internalBootStep.warning.bootMirrorDescription') }}</p>
<p>{{ t('onboarding.internalBootStep.warning.dedicatedMirrorSize') }}</p>
<p class="font-semibold">
{{ t('onboarding.internalBootStep.warning.dedicatedDevicesFormatted') }}
</p>
</div>
</template>
</UAlert>
<UAlert
v-if="isStorageBootSelected && hasEligibleDevices && !isDedicatedMode"
data-testid="internal-boot-intro-panel"
class="my-8"
color="neutral"
variant="subtle"
icon="i-lucide-info"
>
<template #description>
<div class="space-y-3 text-sm leading-relaxed">
<p class="font-semibold">
{{ t('onboarding.internalBootStep.warning.hybridModeTitle') }}
</p>
<p>{{ t('onboarding.internalBootStep.warning.bootablePoolDescription') }}</p>
<p>{{ t('onboarding.internalBootStep.warning.bootablePoolVolumes') }}</p>
<ul class="list-disc space-y-1 pl-5">
@@ -798,41 +883,64 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
</div>
<div v-if="isStorageBootSelected && !isLoading && !contextError && canConfigure" class="space-y-5">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.poolName') }}
</span>
<UInput v-model="poolName" type="text" maxlength="40" :disabled="isBusy" class="w-full" />
</label>
<div class="space-y-3">
<h3 class="text-highlighted text-sm font-bold tracking-wider uppercase">
{{ t('onboarding.internalBootStep.sections.poolSettings') }}
</h3>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.poolMode') }}
</span>
<USelectMenu
v-model="poolMode"
:items="poolModeItems"
label-key="label"
value-key="value"
:search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
/>
</label>
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.slots') }}
</span>
<USelectMenu
:model-value="slotCount"
:items="slotCountItems"
label-key="label"
value-key="value"
:search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
@update:model-value="
(val: unknown) => {
const n = Number(val);
if (Number.isFinite(n) && n >= 1) slotCount = n;
}
"
/>
</label>
<label v-if="!isDedicatedMode" class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.dataPoolName') }}
</span>
<UInput v-model="poolName" type="text" maxlength="40" :disabled="isBusy" class="w-full" />
</label>
</div>
</div>
<div class="space-y-3">
<h3 class="text-highlighted text-sm font-bold tracking-wider uppercase">
{{ t('onboarding.internalBootStep.fields.devices') }}
{{ t('onboarding.internalBootStep.sections.devices') }}
</h3>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.slots') }}
</span>
<USelectMenu
:model-value="slotCount"
:items="slotCountItems"
label-key="label"
value-key="value"
:search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
@update:model-value="
(val: unknown) => {
const n = Number(val);
if (Number.isFinite(n) && n >= 1) slotCount = n;
}
"
/>
</label>
</div>
<div v-for="index in slotCount" :key="index" class="space-y-2">
<label class="text-muted text-sm font-medium">{{
t('onboarding.internalBootStep.fields.deviceSlot', { index })
@@ -873,39 +981,41 @@ const primaryButtonText = computed(() => t('onboarding.internalBootStep.actions.
</template>
</UAlert>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.bootReservedSize') }}
</span>
<USelectMenu
v-model="bootSizePreset"
:items="bootSizePresetItems"
label-key="label"
value-key="value"
:search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
/>
</label>
<template v-if="!isDedicatedMode">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<label class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.bootReservedSize') }}
</span>
<USelectMenu
v-model="bootSizePreset"
:items="bootSizePresetItems"
label-key="label"
value-key="value"
:search-input="false"
:disabled="isBusy"
class="w-full"
:ui="{ content: 'z-[100]' }"
/>
</label>
<label v-if="bootSizePreset === 'custom'" class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.customSizeGb') }}
</span>
<UInput
v-model="customBootSizeGb"
type="number"
min="4"
:max="maxCustomBootSizeGb ?? undefined"
:disabled="isBusy"
class="w-full"
/>
</label>
</div>
<label v-if="bootSizePreset === 'custom'" class="space-y-2">
<span class="text-muted text-sm font-medium">
{{ t('onboarding.internalBootStep.fields.customSizeGb') }}
</span>
<UInput
v-model="customBootSizeGb"
type="number"
min="4"
:max="maxCustomBootSizeGb ?? undefined"
:disabled="isBusy"
class="w-full"
/>
</label>
</div>
<p class="text-muted text-xs">{{ bootSizeHelpText }}</p>
<p class="text-muted text-xs">{{ bootSizeHelpText }}</p>
</template>
<label class="flex items-center gap-3 text-sm">
<UCheckbox

View File

@@ -17,7 +17,11 @@ 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 InternalBootConfirmDialog from '@/components/Onboarding/components/InternalBootConfirmDialog.vue';
import {
submitInternalBootReboot,
submitInternalBootShutdown,
} 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';
@@ -54,13 +58,22 @@ 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 showRebootButton = computed(() => draftStore.internalBootSelection !== null);
const internalBootFailed = computed(
() =>
draftStore.internalBootSelection !== null &&
draftStore.internalBootApplyAttempted &&
!draftStore.internalBootApplySucceeded
);
const biosUpdateMissed = computed(
() => internalBootFailed.value && (draftStore.internalBootSelection?.updateBios ?? false)
);
const primaryButtonText = computed(() =>
showRebootButton.value
? t('onboarding.nextSteps.reboot')
: t('onboarding.nextSteps.continueToDashboard')
);
const showRebootWarningDialog = ref(false);
const pendingPowerAction = ref<'reboot' | 'shutdown' | null>(null);
const isCompleting = ref(false);
const completionError = ref<string | null>(null);
@@ -91,7 +104,7 @@ const handleMouseMove = (e: MouseEvent) => {
el.style.setProperty('--y', `${y}px`);
};
const finishOnboarding = async ({ reboot }: { reboot: boolean }) => {
const finishOnboarding = async ({ action }: { action?: 'reboot' | 'shutdown' } = {}) => {
if (isCompleting.value) {
return;
}
@@ -107,39 +120,54 @@ const finishOnboarding = async ({ reboot }: { reboot: boolean }) => {
} catch (error: unknown) {
console.error('Failed to refresh onboarding state:', error);
}
cleanupOnboardingStorage();
if (reboot) {
submitInternalBootReboot();
return;
}
props.onComplete();
} catch (error: unknown) {
console.error('Failed to complete onboarding:', error);
completionError.value = t('onboarding.nextSteps.completionFailed');
} finally {
isCompleting.value = false;
if (!action) {
completionError.value = t('onboarding.nextSteps.completionFailed');
isCompleting.value = false;
return;
}
}
cleanupOnboardingStorage();
if (action === 'shutdown') {
submitInternalBootShutdown();
return;
}
if (action === 'reboot') {
submitInternalBootReboot();
return;
}
props.onComplete();
isCompleting.value = false;
};
const handlePrimaryAction = async () => {
if (showRebootButton.value) {
showRebootWarningDialog.value = true;
pendingPowerAction.value = 'reboot';
return;
}
await finishOnboarding({ reboot: false });
await finishOnboarding();
};
const handleConfirmReboot = async () => {
showRebootWarningDialog.value = false;
await finishOnboarding({ reboot: true });
const handleShutdownAction = () => {
pendingPowerAction.value = 'shutdown';
};
const handleCancelReboot = () => {
showRebootWarningDialog.value = false;
const handleConfirmPowerAction = async () => {
const action = pendingPowerAction.value;
pendingPowerAction.value = null;
if (action) {
await finishOnboarding({ action });
}
};
const handleCancelPowerAction = () => {
pendingPowerAction.value = null;
};
</script>
@@ -393,36 +421,29 @@ const handleCancelReboot = () => {
</div>
</div>
<UModal
:open="showRebootWarningDialog"
:portal="false"
:title="t('onboarding.nextSteps.confirmReboot.title')"
:description="t('onboarding.nextSteps.confirmReboot.description')"
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50 max-w-md' }"
@update:open="showRebootWarningDialog = $event"
>
<template #body>
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('onboarding.nextSteps.confirmReboot.warning')"
/>
</template>
<template #footer>
<UButton
color="neutral"
variant="outline"
:disabled="isCompleting"
@click="handleCancelReboot"
>
{{ t('common.cancel') }}
</UButton>
<UButton :disabled="isCompleting" @click="handleConfirmReboot">
{{ t('onboarding.nextSteps.confirmReboot.confirm') }}
</UButton>
</template>
</UModal>
<InternalBootConfirmDialog
:open="pendingPowerAction !== null"
:action="pendingPowerAction ?? 'reboot'"
:disabled="isCompleting"
@confirm="handleConfirmPowerAction"
@cancel="handleCancelPowerAction"
/>
<div v-if="internalBootFailed" class="mt-6 space-y-3">
<UAlert
color="warning"
variant="subtle"
icon="i-lucide-triangle-alert"
:description="t('onboarding.nextSteps.internalBootFailed')"
/>
<UAlert
v-if="biosUpdateMissed"
color="warning"
variant="subtle"
icon="i-lucide-info"
:description="t('onboarding.nextSteps.internalBootBiosMissed')"
/>
</div>
<p v-if="completionError" role="alert" aria-live="polite" class="mt-6 text-sm text-red-600">
{{ completionError }}
@@ -442,13 +463,23 @@ const handleCancelReboot = () => {
</button>
<div v-else class="hidden w-1 sm:block" />
<BrandButton
:text="primaryButtonText"
:disabled="isCompleting"
class="!bg-primary hover:!bg-primary/90 w-full min-w-[200px] !text-white shadow-md transition-all hover:shadow-lg sm:w-auto"
@click="handlePrimaryAction"
:icon-right="CheckCircleIcon"
/>
<div class="flex w-full items-center justify-end gap-4 sm:w-auto">
<button
v-if="showRebootButton"
:disabled="isCompleting"
class="text-muted hover:text-highlighted text-sm font-medium transition-colors disabled:opacity-50"
@click="handleShutdownAction"
>
{{ t('onboarding.nextSteps.shutdown') }}
</button>
<BrandButton
:text="primaryButtonText"
:disabled="isCompleting"
class="!bg-primary hover:!bg-primary/90 w-full min-w-[200px] !text-white shadow-md transition-all hover:shadow-lg sm:w-auto"
@click="handlePrimaryAction"
:icon-right="CheckCircleIcon"
/>
</div>
</div>
</div>
</div>

View File

@@ -208,6 +208,7 @@ const internalBootSummary = computed(() => {
}
return {
poolMode: selection.poolMode ?? 'hybrid',
poolName: selection.poolName,
slotCount: selection.slotCount,
devices: selection.devices,
@@ -922,6 +923,7 @@ const handleComplete = async () => {
// 3. Internal boot setup
if (internalBootSelection.value) {
const selection = internalBootSelection.value;
draftStore.setInternalBootApplyAttempted(true);
addLog(summaryT('logs.internalBootStart'), 'info');
addLog(summaryT('logs.internalBootConfiguring'), 'info');
const internalBootProgressTimer = setInterval(() => {
@@ -1269,6 +1271,17 @@ const handleBack = () => {
<template v-if="internalBootSummary">
<div class="flex flex-col gap-1 text-sm sm:flex-row sm:items-start sm:justify-between">
<span class="text-muted">{{ t('onboarding.summaryStep.bootConfig.poolMode') }}</span>
<span class="text-highlighted font-medium break-all sm:text-right">{{
internalBootSummary.poolMode === 'dedicated'
? t('onboarding.summaryStep.bootConfig.poolModeDedicated')
: t('onboarding.summaryStep.bootConfig.poolModeHybrid')
}}</span>
</div>
<div
v-if="internalBootSummary.poolMode !== 'dedicated'"
class="flex flex-col gap-1 text-sm sm:flex-row sm:items-start sm:justify-between"
>
<span class="text-muted">{{ t('onboarding.summaryStep.bootConfig.pool') }}</span>
<span class="text-highlighted font-medium break-all sm:text-right">{{
internalBootSummary.poolName
@@ -1280,7 +1293,10 @@ const handleBack = () => {
internalBootSummary.slotCount
}}</span>
</div>
<div class="flex flex-col gap-1 text-sm sm:flex-row sm:items-start sm:justify-between">
<div
v-if="internalBootSummary.poolMode !== 'dedicated'"
class="flex flex-col gap-1 text-sm sm:flex-row sm:items-start sm:justify-between"
>
<span class="text-muted">{{ t('onboarding.summaryStep.bootConfig.bootReserved') }}</span>
<span class="text-highlighted font-medium break-all sm:text-right">{{
internalBootSummary.bootReservedSize

View File

@@ -5,12 +5,15 @@ 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';
@@ -54,6 +57,13 @@ const normalizePersistedPlugins = (value: unknown): string[] => {
return [];
};
const normalizePersistedPoolMode = (value: unknown): OnboardingPoolMode => {
if (value === 'dedicated' || value === 'hybrid') {
return value;
}
return 'hybrid';
};
const normalizePersistedInternalBootSelection = (
value: unknown
): OnboardingInternalBootSelection | null => {
@@ -67,8 +77,10 @@ const normalizePersistedInternalBootSelection = (
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;
@@ -76,7 +88,12 @@ const normalizePersistedInternalBootSelection = (
? candidate.devices.filter((item): item is string => typeof item === 'string')
: [];
const parsedBootSize = Number(candidate.bootSizeMiB);
const bootSizeMiB = Number.isFinite(parsedBootSize) && parsedBootSize > 0 ? parsedBootSize : 16384;
const bootSizeMiB =
poolMode === 'dedicated'
? 0
: Number.isFinite(parsedBootSize) && parsedBootSize > 0
? parsedBootSize
: 16384;
return {
poolName,
@@ -84,6 +101,7 @@ const normalizePersistedInternalBootSelection = (
devices,
bootSizeMiB,
updateBios: normalizePersistedBoolean(candidate.updateBios, false),
poolMode,
};
};
@@ -127,6 +145,7 @@ export const useOnboardingDraftStore = defineStore(
const internalBootInitialized = ref(false);
const internalBootSkipped = ref(false);
const internalBootApplySucceeded = ref(false);
const internalBootApplyAttempted = ref(false);
// Navigation
const currentStepId = ref<StepId | null>(null);
@@ -156,6 +175,7 @@ export const useOnboardingDraftStore = defineStore(
internalBootInitialized.value = false;
internalBootSkipped.value = false;
internalBootApplySucceeded.value = false;
internalBootApplyAttempted.value = false;
currentStepId.value = null;
}
@@ -190,6 +210,7 @@ export const useOnboardingDraftStore = defineStore(
devices: [...selection.devices],
bootSizeMiB: selection.bootSizeMiB,
updateBios: selection.updateBios,
poolMode: selection.poolMode,
};
bootMode.value = 'storage';
internalBootInitialized.value = true;
@@ -219,6 +240,10 @@ export const useOnboardingDraftStore = defineStore(
internalBootApplySucceeded.value = value;
}
function setInternalBootApplyAttempted(value: boolean) {
internalBootApplyAttempted.value = value;
}
function setCurrentStep(stepId: StepId) {
currentStepId.value = stepId;
}
@@ -238,6 +263,7 @@ export const useOnboardingDraftStore = defineStore(
internalBootInitialized,
internalBootSkipped,
internalBootApplySucceeded,
internalBootApplyAttempted,
currentStepId,
hasResumableDraft,
resetDraft,
@@ -247,6 +273,7 @@ export const useOnboardingDraftStore = defineStore(
skipInternalBoot,
setBootMode,
setInternalBootApplySucceeded,
setInternalBootApplyAttempted,
setCurrentStep,
};
},
@@ -294,6 +321,10 @@ export const useOnboardingDraftStore = defineStore(
parsed.internalBootApplySucceeded,
false
),
internalBootApplyAttempted: normalizePersistedBoolean(
parsed.internalBootApplyAttempted,
false
),
currentStepId: normalizedCurrentStepId,
coreSettingsInitialized:
hasLegacyCoreDraft || normalizePersistedBoolean(parsed.coreSettingsInitialized, false),

View File

@@ -2,22 +2,16 @@ import { computed, onMounted, onUnmounted, ref } from 'vue';
import { defineStore, storeToRefs } from 'pinia';
import { useMutation } from '@vue/apollo-composable';
import { BYPASS_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/bypassOnboarding.mutation';
import { CLOSE_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/closeOnboarding.mutation';
import { OPEN_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/openOnboarding.mutation';
import { RESUME_ONBOARDING_MUTATION } from '~/components/Onboarding/graphql/resumeOnboarding.mutation';
import { useOnboardingStore } from '~/components/Onboarding/store/onboardingStatus';
import {
clearLegacyOnboardingModalHiddenSessionState,
clearOnboardingDraftStorage,
} from '~/components/Onboarding/store/onboardingStorageCleanup';
import { clearLegacyOnboardingModalHiddenSessionState } from '~/components/Onboarding/store/onboardingStorageCleanup';
import { useCallbackActionsStore } from '~/store/callbackActions';
const ONBOARDING_QUERY_ACTION_PARAM = 'onboarding';
const ONBOARDING_URL_ACTION_BYPASS = 'bypass';
const ONBOARDING_URL_ACTION_RESUME = 'resume';
const ONBOARDING_URL_ACTION_OPEN = 'open';
const ONBOARDING_BYPASS_SHORTCUT_CODE = 'KeyO';
const ONBOARDING_FORCE_OPEN_EVENT = 'unraid:onboarding:open';
export type OnboardingModalSessionSource = 'automatic' | 'manual';
@@ -29,7 +23,6 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
const { callbackData } = storeToRefs(useCallbackActionsStore());
const { mutate: openOnboardingMutation } = useMutation(OPEN_ONBOARDING_MUTATION);
const { mutate: closeOnboardingMutation } = useMutation(CLOSE_ONBOARDING_MUTATION);
const { mutate: bypassOnboardingMutation } = useMutation(BYPASS_ONBOARDING_MUTATION);
const { mutate: resumeOnboardingMutation } = useMutation(RESUME_ONBOARDING_MUTATION);
const sessionSource = ref<OnboardingModalSessionSource>('automatic');
@@ -61,12 +54,6 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
const resetToAutomaticVisibility = async () => closeModal();
const bypassOnboarding = async () => {
clearOnboardingDraftStorage();
await bypassOnboardingMutation();
await refetchOnboarding();
};
const resumeOnboarding = async () => {
sessionSource.value = 'manual';
await resumeOnboardingMutation();
@@ -81,9 +68,7 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
const url = new URL(window.location.href);
const action = url.searchParams.get(ONBOARDING_QUERY_ACTION_PARAM);
if (action === ONBOARDING_URL_ACTION_BYPASS) {
await bypassOnboarding();
} else if (action === ONBOARDING_URL_ACTION_RESUME) {
if (action === ONBOARDING_URL_ACTION_RESUME) {
await resumeOnboarding();
} else if (action === ONBOARDING_URL_ACTION_OPEN) {
await forceOpenModal();
@@ -96,26 +81,6 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
window.history.replaceState(window.history.state ?? null, '', nextPath || '/');
};
const isBypassShortcut = (event: KeyboardEvent) => {
if (event.repeat) {
return false;
}
const isPrimaryModifierPressed = event.ctrlKey || event.metaKey;
return (
isPrimaryModifierPressed &&
event.altKey &&
event.shiftKey &&
event.code === ONBOARDING_BYPASS_SHORTCUT_CODE
);
};
const handleKeydown = (event: KeyboardEvent) => {
if (isBypassShortcut(event)) {
event.preventDefault();
void bypassOnboarding();
}
};
const handleForceOpen = () => {
void forceOpenModal();
};
@@ -123,12 +88,10 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
onMounted(() => {
clearLegacyOnboardingModalHiddenSessionState();
void applyOnboardingUrlAction();
window?.addEventListener('keydown', handleKeydown);
window?.addEventListener(ONBOARDING_FORCE_OPEN_EVENT, handleForceOpen);
});
onUnmounted(() => {
window?.removeEventListener('keydown', handleKeydown);
window?.removeEventListener(ONBOARDING_FORCE_OPEN_EVENT, handleForceOpen);
});
@@ -137,7 +100,6 @@ export const useOnboardingModalStore = defineStore('onboardingModalVisibility',
sessionSource,
forceOpenModal,
closeModal,
bypassOnboarding,
resumeOnboarding,
resetToAutomaticVisibility,
applyOnboardingUrlAction,

View File

@@ -116,8 +116,12 @@
"onboarding.nextSteps.completionFailed": "We couldn't finish onboarding right now. Please try again.",
"onboarding.nextSteps.confirmReboot.title": "Confirm Reboot",
"onboarding.nextSteps.confirmReboot.description": "On some systems, you may need to manually change the BIOS boot order from the USB device to the storage drive.",
"onboarding.nextSteps.confirmReboot.warning": "Please do NOT remove your Unraid flash drive until your server has finished rebooting into Unraid again.",
"onboarding.nextSteps.confirmReboot.warning": "Please do NOT remove your Unraid flash drive until your server has fully restarted into Unraid.",
"onboarding.nextSteps.confirmReboot.confirm": "I Understand",
"onboarding.nextSteps.shutdown": "Shutdown",
"onboarding.nextSteps.confirmShutdown.title": "Confirm Shutdown",
"onboarding.nextSteps.internalBootFailed": "Internal boot timed out but is likely setup on your server. Please reboot your system to finalize setup.",
"onboarding.nextSteps.internalBootBiosMissed": "The BIOS boot order update could not be applied automatically. After rebooting, you may need to manually update your BIOS boot order to prioritize the storage drive.",
"onboarding.stepper.stepLabel": "Step {number}",
"onboarding.console.title": "Setup Console",
"onboarding.console.waiting": "Waiting...",
@@ -161,7 +165,7 @@
"onboarding.internalBootStep.loadingOptions": "Loading internal boot options...",
"onboarding.internalBootStep.unknownSize": "Unknown",
"onboarding.internalBootStep.warning.bootablePoolDescription": "A bootable pool allows Unraid to boot from internal drives instead of a USB flash device.",
"onboarding.internalBootStep.warning.bootablePoolVolumes": "Each bootable pool contains two volumes:",
"onboarding.internalBootStep.warning.bootablePoolVolumes": "Each device contains two volumes:",
"onboarding.internalBootStep.warning.systemBootVolume": "a system boot volume used by Unraid",
"onboarding.internalBootStep.warning.storagePoolVolume": "a storage pool for general data",
"onboarding.internalBootStep.warning.storagePoolNaming": "The name you choose below applies to the storage pool, not the boot volume.",
@@ -170,6 +174,18 @@
"onboarding.internalBootStep.warning.driveWarningsTitle": "Selected drive warnings",
"onboarding.internalBootStep.warning.driveWarningsDescription": "These selected drives already contain internal boot partitions. Continuing will reconfigure them.",
"onboarding.internalBootStep.warning.updateBios": "On some systems, you may need to manually change the BIOS boot order from the USB device to the storage drive.",
"onboarding.internalBootStep.sections.poolSettings": "Pool Settings",
"onboarding.internalBootStep.sections.devices": "Devices",
"onboarding.internalBootStep.fields.poolMode": "Pool mode",
"onboarding.internalBootStep.fields.dataPoolName": "Data pool name",
"onboarding.internalBootStep.poolMode.dedicated": "Dedicated boot pool",
"onboarding.internalBootStep.poolMode.hybrid": "Boot + data pool",
"onboarding.internalBootStep.warning.dedicatedModeTitle": "Dedicated boot pool",
"onboarding.internalBootStep.warning.dedicatedPoolDescription": "The entire device will be used exclusively for booting Unraid. No additional storage pool will be created on this device.",
"onboarding.internalBootStep.warning.dedicatedMirrorSize": "In a 2-device mirror, the boot partition size is determined by the smallest selected device.",
"onboarding.internalBootStep.warning.dedicatedDevicesFormatted": "All selected devices will be formatted.",
"onboarding.internalBootStep.warning.hybridModeTitle": "Boot + data pool",
"onboarding.internalBootStep.validation.dedicatedPoolNameConflict": "The default pool name \"boot\" conflicts with an existing pool or reserved name. Please resolve the conflict before using dedicated boot mode.",
"onboarding.internalBootStep.fields.poolName": "Pool name",
"onboarding.internalBootStep.fields.slots": "Boot devices",
"onboarding.internalBootStep.fields.devices": "Devices",
@@ -236,6 +252,9 @@
"onboarding.summaryStep.bootConfig.bootMethod": "Boot Method",
"onboarding.summaryStep.bootConfig.bootMethodStorage": "Storage Drive(s)",
"onboarding.summaryStep.bootConfig.bootMethodUsb": "USB/Flash Drive",
"onboarding.summaryStep.bootConfig.poolMode": "Pool mode",
"onboarding.summaryStep.bootConfig.poolModeDedicated": "Dedicated boot pool",
"onboarding.summaryStep.bootConfig.poolModeHybrid": "Boot + data pool",
"onboarding.summaryStep.bootConfig.pool": "Pool",
"onboarding.summaryStep.bootConfig.slots": "Boot devices",
"onboarding.summaryStep.bootConfig.bootReserved": "Boot Reserved",