mirror of
https://github.com/unraid/api.git
synced 2026-04-23 15:51:24 -05:00
9323b14879
## 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 -->
264 lines
7.0 KiB
TypeScript
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');
|