Files
api/web/src/components/Onboarding/composables/internalBoot.ts
T
Ajit Mehrotra 9323b14879 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 -->
2026-03-26 20:47:58 -04:00

264 lines
7.0 KiB
TypeScript

import { useApolloClient } from '@vue/apollo-composable';
import { buildOnboardingErrorDiagnostics } from '@/components/Onboarding/composables/onboardingErrorDiagnostics';
import { CREATE_INTERNAL_BOOT_POOL_MUTATION } from '@/components/Onboarding/graphql/createInternalBootPool.mutation';
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 {
reboot?: boolean;
}
export interface InternalBootSubmitResult {
ok: boolean;
code?: number;
output: string;
}
export interface InternalBootApplyMessages {
configured: string;
returnedError: (output: string) => string;
failed: string;
biosUnverified: string;
}
export interface InternalBootApplyResult {
applySucceeded: boolean;
hadWarnings: boolean;
hadNonOptimisticFailures: boolean;
logs: Array<Omit<LogEntry, 'timestamp'>>;
}
interface InternalBootBiosLogSummary {
summaryLine: string | null;
failureLines: string[];
}
const readCsrfToken = (): string | null => {
const token = globalThis.csrf_token;
if (typeof token !== 'string') {
return null;
}
const trimmedToken = token.trim();
return trimmedToken.length > 0 ? trimmedToken : null;
};
export const submitInternalBootCreation = async (
selection: InternalBootSelection,
options: SubmitInternalBootOptions = {}
): Promise<InternalBootSubmitResult> => {
const apolloClient = useApolloClient().client;
try {
const { data } = await apolloClient.mutate({
mutation: CREATE_INTERNAL_BOOT_POOL_MUTATION,
variables: {
input: {
poolName: selection.poolName,
devices: selection.devices,
bootSizeMiB: selection.bootSizeMiB,
updateBios: selection.updateBios,
reboot: Boolean(options.reboot),
},
},
fetchPolicy: 'no-cache',
});
const result = data?.onboarding?.createInternalBootPool;
if (!result) {
return {
ok: false,
output: 'Internal boot setup request failed: empty API response.',
};
}
return {
ok: result.ok,
code: result.code ?? undefined,
output: result.output?.trim() || 'No output',
};
} catch (error) {
return {
ok: false,
output:
error instanceof Error
? `Internal boot setup request failed: ${error.message}`
: 'Internal boot setup request failed.',
};
}
};
export const summarizeInternalBootBiosLogs = (output: string): InternalBootBiosLogSummary => {
const lines = output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const summaryLine = lines.find((line) => line.startsWith('BIOS boot entry updates completed')) ?? null;
const failureLines = Array.from(
new Set(lines.filter((line) => line.toLowerCase().includes('efibootmgr failed')))
);
return { summaryLine, failureLines };
};
const buildMutationVariables = (selection: InternalBootSelection) => ({
poolName: selection.poolName,
devices: selection.devices,
bootSizeMiB: selection.bootSizeMiB,
updateBios: selection.updateBios,
reboot: false,
});
export const getErrorMessage = (error: unknown): string => {
if (error instanceof Error) {
const trimmedMessage = error.message.trim();
if (trimmedMessage) {
return trimmedMessage;
}
}
if (typeof error === 'string') {
const trimmedMessage = error.trim();
if (trimmedMessage) {
return trimmedMessage;
}
}
return 'Unknown error';
};
export const applyInternalBootSelection = async (
selection: InternalBootSelection,
messages: InternalBootApplyMessages
): Promise<InternalBootApplyResult> => {
const logs: Array<Omit<LogEntry, 'timestamp'>> = [];
let hadWarnings = false;
let hadNonOptimisticFailures = false;
try {
const result = await submitInternalBootCreation(selection, { reboot: false });
if (result.ok) {
logs.push({
message: messages.configured,
type: 'success',
});
if (selection.updateBios) {
const biosLogSummary = summarizeInternalBootBiosLogs(result.output);
const hadBiosWarnings =
biosLogSummary.failureLines.length > 0 ||
Boolean(biosLogSummary.summaryLine?.toLowerCase().includes('with warnings'));
const biosUnverified = !biosLogSummary.summaryLine && biosLogSummary.failureLines.length === 0;
if (hadBiosWarnings || biosUnverified) {
hadWarnings = true;
hadNonOptimisticFailures = true;
}
if (biosUnverified) {
logs.push({
message: messages.biosUnverified,
type: 'error',
});
} else if (biosLogSummary.summaryLine) {
logs.push({
message: biosLogSummary.summaryLine,
type: hadBiosWarnings ? 'error' : 'success',
});
}
for (const failureLine of biosLogSummary.failureLines) {
logs.push({
message: failureLine,
type: 'error',
});
}
}
return {
applySucceeded: true,
hadWarnings,
hadNonOptimisticFailures,
logs,
};
}
hadWarnings = true;
hadNonOptimisticFailures = true;
logs.push({
message: messages.returnedError(result.output),
type: 'error',
details: buildOnboardingErrorDiagnostics(
{
message: 'Internal boot setup returned ok=false',
code: result.code ?? null,
networkError: {
status: result.code ?? null,
result,
},
},
{
operation: 'CreateInternalBootPool',
variables: buildMutationVariables(selection),
}
),
});
} catch (error) {
hadWarnings = true;
hadNonOptimisticFailures = true;
logs.push({
message: `${messages.failed}: ${getErrorMessage(error)}`,
type: 'error',
details: buildOnboardingErrorDiagnostics(error, {
operation: 'CreateInternalBootPool',
variables: buildMutationVariables(selection),
}),
});
}
return {
applySucceeded: false,
hadWarnings,
hadNonOptimisticFailures,
logs,
};
};
const submitBootCommand = (command: 'reboot' | 'shutdown') => {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/plugins/dynamix/include/Boot.php';
form.target = '_top';
form.style.display = 'none';
const cmd = document.createElement('input');
cmd.type = 'hidden';
cmd.name = 'cmd';
cmd.value = command;
form.appendChild(cmd);
const csrfToken = readCsrfToken();
if (csrfToken) {
const csrf = document.createElement('input');
csrf.type = 'hidden';
csrf.name = 'csrf_token';
csrf.value = csrfToken;
form.appendChild(csrf);
}
document.body.appendChild(form);
form.submit();
};
export const submitInternalBootReboot = () => submitBootCommand('reboot');
export const submitInternalBootShutdown = () => submitBootCommand('shutdown');