mirror of
https://github.com/unraid/api.git
synced 2026-04-03 21:22:45 -05:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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?');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
1
web/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user