From d2ce62729b38973f0751aec40f9ce0f44c8eb90c Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 10 Oct 2025 00:11:04 -0400 Subject: [PATCH] feat(upgrade): implement upgrade onboarding system for Unraid OS - Added `UpgradeInfo` type to track OS version changes, including current and previous versions. - Enhanced `InfoVersions` and GraphQL resolvers to expose upgrade information. - Introduced `upgradeOnboarding` store to manage visibility and steps for users upgrading their OS. - Updated `ActivationModal` to handle both fresh installs and upgrade onboarding, displaying relevant steps based on the user's upgrade path. - Created configuration for defining upgrade steps and conditions in `releaseConfigs.ts`. - Added new components and logic to facilitate the upgrade onboarding experience, improving user guidance during OS upgrades. This update streamlines the upgrade process, ensuring users receive contextual onboarding steps when upgrading their Unraid OS, enhancing overall user experience. --- api/dev/configs/api.json | 3 +- api/generated-schema.graphql | 14 ++ api/src/unraid-api/cli/generated/graphql.ts | 74 ++++++++ api/src/unraid-api/cli/generated/index.ts | 4 +- .../unraid-api/config/api-config.module.ts | 15 ++ .../resolvers/info/versions/versions.model.ts | 15 ++ .../info/versions/versions.resolver.ts | 17 ++ .../unraid-shared/src/services/api-config.ts | 5 + .../Activation/ActivationSteps.test.ts | 117 ------------ web/components.d.ts | 1 + web/public/test-pages/shared-header.js | 67 +++++++ .../components/Activation/ActivationModal.vue | 177 +++++++++++++----- .../Activation/ActivationPluginsStep.vue | 14 ++ .../components/Activation/ActivationSteps.vue | 150 ++++----------- .../Activation/ActivationTimezoneStep.vue | 14 ++ .../Activation/UPGRADE_ONBOARDING.md | 135 +++++++++++++ .../components/Activation/releaseConfigs.ts | 82 ++++++++ .../Activation/store/activationCodeModal.ts | 6 +- .../Activation/store/upgradeOnboarding.ts | 62 ++++++ .../Activation/upgradeInfo.query.ts | 17 ++ .../components/ColorSwitcher.standalone.vue | 4 +- .../TestThemeSwitcher.standalone.vue | 4 +- web/src/composables/gql/gql.ts | 6 + web/src/composables/gql/graphql.ts | 18 ++ 24 files changed, 733 insertions(+), 288 deletions(-) delete mode 100644 web/__test__/components/Activation/ActivationSteps.test.ts create mode 100644 web/src/components/Activation/UPGRADE_ONBOARDING.md create mode 100644 web/src/components/Activation/releaseConfigs.ts create mode 100644 web/src/components/Activation/store/upgradeOnboarding.ts create mode 100644 web/src/components/Activation/upgradeInfo.query.ts diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index f4f76b8f5..b09f90470 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -5,5 +5,6 @@ "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" - ] + ], + "lastSeenOsVersion": "6.11.2" } \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 39d4e8245..cf9fce3b3 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1964,6 +1964,17 @@ type PackageVersions { docker: String } +type UpgradeInfo { + """Whether the OS version has changed since last boot""" + isUpgrade: Boolean! + + """Previous OS version before upgrade""" + previousVersion: String + + """Current OS version""" + currentVersion: String +} + type InfoVersions implements Node { id: PrefixedID! @@ -1972,6 +1983,9 @@ type InfoVersions implements Node { """Software package versions""" packages: PackageVersions + + """OS upgrade information""" + upgrade: UpgradeInfo! } type Info implements Node { diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 27032c24b..a96c58ee7 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -560,6 +560,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -591,6 +602,19 @@ export type Customization = { theme: Theme; }; +/** Customization related mutations */ +export type CustomizationMutations = { + __typename?: 'CustomizationMutations'; + /** Update the UI theme (writes dynamix.cfg) */ + setTheme: Theme; +}; + + +/** Customization related mutations */ +export type CustomizationMutationsSetThemeArgs = { + theme: ThemeName; +}; + export type DeleteApiKeyInput = { ids: Array; }; @@ -1065,6 +1089,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -1081,6 +1106,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1282,6 +1309,8 @@ export type InfoVersions = Node & { id: Scalars['PrefixedID']['output']; /** Software package versions */ packages?: Maybe; + /** OS upgrade information */ + upgrade: UpgradeInfo; }; export type InitiateFlashBackupInput = { @@ -1422,6 +1451,7 @@ export type Mutation = { createDockerFolderWithItems: ResolvedOrganizerV1; /** Creates a new notification record */ createNotification: Notification; + customization: CustomizationMutations; /** Deletes all archived notifications on server. */ deleteArchivedNotifications: NotificationOverview; deleteDockerEntries: ResolvedOrganizerV1; @@ -1454,6 +1484,8 @@ export type Mutation = { updateApiSettings: ConnectSettingsValues; updateDockerViewPreferences: ResolvedOrganizerV1; updateSettings: UpdateSettingsResponse; + /** Update system time configuration */ + updateSystemTime: SystemTime; vm: VmMutations; }; @@ -1599,6 +1631,11 @@ export type MutationUpdateSettingsArgs = { input: Scalars['JSON']['input']; }; + +export type MutationUpdateSystemTimeArgs = { + input: UpdateSystemTimeInput; +}; + export type Network = Node & { __typename?: 'Network'; accessUrls?: Maybe>; @@ -1928,6 +1965,8 @@ export type Query = { services: Array; settings: Settings; shares: Array; + /** Retrieve current system time configuration */ + systemTime: SystemTime; upsConfiguration: UpsConfiguration; upsDeviceById?: Maybe; upsDevices: Array; @@ -2269,6 +2308,7 @@ export type Subscription = { parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; @@ -2278,6 +2318,19 @@ export type SubscriptionLogFileArgs = { path: Scalars['String']['input']; }; +/** System time configuration and current status */ +export type SystemTime = { + __typename?: 'SystemTime'; + /** Current server time in ISO-8601 format (UTC) */ + currentTime: Scalars['String']['output']; + /** Configured NTP servers (empty strings indicate unused slots) */ + ntpServers: Array; + /** IANA timezone identifier currently in use */ + timeZone: Scalars['String']['output']; + /** Whether NTP/PTP time synchronization is enabled */ + useNtp: Scalars['Boolean']['output']; +}; + /** Tailscale exit node connection status */ export type TailscaleExitNodeStatus = { __typename?: 'TailscaleExitNodeStatus'; @@ -2548,6 +2601,27 @@ export enum UpdateStatus { UP_TO_DATE = 'UP_TO_DATE' } +export type UpdateSystemTimeInput = { + /** Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss */ + manualDateTime?: InputMaybe; + /** Ordered list of up to four NTP servers. Supply empty strings to clear positions. */ + ntpServers?: InputMaybe>; + /** New IANA timezone identifier to apply */ + timeZone?: InputMaybe; + /** Enable or disable NTP-based synchronization */ + useNtp?: InputMaybe; +}; + +export type UpgradeInfo = { + __typename?: 'UpgradeInfo'; + /** Current OS version */ + currentVersion?: Maybe; + /** Whether the OS version has changed since last boot */ + isUpgrade: Scalars['Boolean']['output']; + /** Previous OS version before upgrade */ + previousVersion?: Maybe; +}; + export type Uptime = { __typename?: 'Uptime'; timestamp?: Maybe; diff --git a/api/src/unraid-api/cli/generated/index.ts b/api/src/unraid-api/cli/generated/index.ts index 873144cb2..6cf863446 100644 --- a/api/src/unraid-api/cli/generated/index.ts +++ b/api/src/unraid-api/cli/generated/index.ts @@ -1,2 +1,2 @@ -export * from './fragment-masking.js'; -export * from './gql.js'; +export * from "./fragment-masking.js"; +export * from "./gql.js"; \ No newline at end of file diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index a3daf5f88..369accb31 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -17,6 +17,7 @@ const createDefaultConfig = (): ApiConfig => ({ sandbox: false, ssoSubIds: [], plugins: [], + lastSeenOsVersion: undefined, }); /** @@ -87,6 +88,20 @@ export class ApiConfigPersistence async onApplicationBootstrap() { this.configService.set('api.version', API_VERSION); + await this.trackOsVersionUpgrade(); + } + + private async trackOsVersionUpgrade() { + const currentOsVersion = this.configService.get('store.emhttp.var.version'); + const lastSeenOsVersion = this.configService.get('api.lastSeenOsVersion'); + + if (currentOsVersion && currentOsVersion !== lastSeenOsVersion) { + this.configService.set('api.lastSeenOsVersion', currentOsVersion); + const currentConfig = this.configService.get('api'); + if (currentConfig) { + await this.persist(currentConfig); + } + } } async migrateConfig(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts index dd6fe5d88..d8fbd3b96 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.model.ts @@ -41,6 +41,18 @@ export class PackageVersions { docker?: string; } +@ObjectType() +export class UpgradeInfo { + @Field(() => Boolean, { description: 'Whether the OS version has changed since last boot' }) + isUpgrade!: boolean; + + @Field(() => String, { nullable: true, description: 'Previous OS version before upgrade' }) + previousVersion?: string; + + @Field(() => String, { nullable: true, description: 'Current OS version' }) + currentVersion?: string; +} + @ObjectType({ implements: () => Node }) export class InfoVersions extends Node { @Field(() => CoreVersions, { description: 'Core system versions' }) @@ -48,4 +60,7 @@ export class InfoVersions extends Node { @Field(() => PackageVersions, { nullable: true, description: 'Software package versions' }) packages?: PackageVersions; + + @Field(() => UpgradeInfo, { description: 'OS upgrade information' }) + upgrade!: UpgradeInfo; } diff --git a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts index a711a17dd..f8ac99c9f 100644 --- a/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/info/versions/versions.resolver.ts @@ -7,6 +7,7 @@ import { CoreVersions, InfoVersions, PackageVersions, + UpgradeInfo, } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js'; @Resolver(() => InfoVersions) @@ -45,4 +46,20 @@ export class VersionsResolver { return null; } } + + @ResolveField(() => UpgradeInfo) + upgrade(): UpgradeInfo { + const currentVersion = this.configService.get('store.emhttp.var.version'); + const lastSeenVersion = this.configService.get('api.lastSeenOsVersion'); + + const isUpgrade = Boolean( + lastSeenVersion && currentVersion && lastSeenVersion !== currentVersion + ); + + return { + isUpgrade, + previousVersion: isUpgrade ? lastSeenVersion : undefined, + currentVersion: currentVersion || undefined, + }; + } } diff --git a/packages/unraid-shared/src/services/api-config.ts b/packages/unraid-shared/src/services/api-config.ts index d9179fcc3..32aa691f7 100644 --- a/packages/unraid-shared/src/services/api-config.ts +++ b/packages/unraid-shared/src/services/api-config.ts @@ -26,4 +26,9 @@ export class ApiConfig { @IsArray() @IsString({ each: true }) plugins!: string[]; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + lastSeenOsVersion?: string; } diff --git a/web/__test__/components/Activation/ActivationSteps.test.ts b/web/__test__/components/Activation/ActivationSteps.test.ts deleted file mode 100644 index b0ae3437d..000000000 --- a/web/__test__/components/Activation/ActivationSteps.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -/** - * ActivationSteps Component Test Coverage - */ - -import { mount } from '@vue/test-utils'; - -import { describe, expect, it, vi } from 'vitest'; - -import ActivationSteps from '~/components/Activation/ActivationSteps.vue'; -import { createTestI18n } from '../../utils/i18n'; - -interface Props { - activeStep?: number; - showActivationStep?: boolean; -} - -vi.mock('@unraid/ui', () => ({ - Stepper: { - template: '
', - props: ['defaultValue'], - }, - StepperItem: { - template: '
', - props: ['step', 'disabled'], - data() { - return { - state: 'active', - }; - }, - }, - StepperTrigger: { - template: '
', - }, - StepperTitle: { - template: '
', - }, - StepperDescription: { - template: '
', - }, - StepperSeparator: { - template: '
', - }, - Button: { - template: '', - }, -})); - -vi.mock('@heroicons/vue/24/outline', () => ({ - CheckIcon: { template: '
' }, - ClockIcon: { template: '
' }, - KeyIcon: { template: '
' }, - ServerStackIcon: { template: '
' }, -})); - -vi.mock('@heroicons/vue/24/solid', () => ({ - ClockIcon: { template: '
' }, - KeyIcon: { template: '
' }, - LockClosedIcon: { template: '
' }, - ServerStackIcon: { template: '
' }, -})); - -describe('ActivationSteps', () => { - const mountComponent = (props: Props = {}) => { - return mount(ActivationSteps, { - props, - global: { - plugins: [createTestI18n()], - }, - }); - }; - - it('renders all four steps with correct titles and descriptions', () => { - const wrapper = mountComponent(); - const titles = wrapper.findAll('[data-testid="stepper-title"]'); - const descriptions = wrapper.findAll('[data-testid="stepper-description"]'); - - expect(titles).toHaveLength(4); - expect(descriptions).toHaveLength(4); - - expect(titles[0].text()).toBe('Create Device Password'); - expect(descriptions[0].text()).toBe('Secure your device'); - - expect(titles[1].text()).toBe('Configure Basic Settings'); - expect(descriptions[1].text()).toBe('Set up system preferences'); - - expect(titles[2].text()).toBe('Activate License'); - expect(descriptions[2].text()).toBe('Create an Unraid.net account and activate your key'); - - expect(titles[3].text()).toBe('Unleash Your Hardware'); - expect(descriptions[3].text()).toBe('Device is ready to configure'); - }); - - it('uses default activeStep of 1 when not provided', () => { - const wrapper = mountComponent(); - - expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('1'); - }); - - it('uses provided activeStep value', () => { - const wrapper = mountComponent({ activeStep: 2 }); - - expect(wrapper.find('[data-testid="stepper"]').attributes('default-value')).toBe('2'); - }); - - it('hides activation step when showActivationStep is false', () => { - const wrapper = mountComponent({ showActivationStep: false }); - const titles = wrapper.findAll('[data-testid="stepper-title"]'); - const descriptions = wrapper.findAll('[data-testid="stepper-description"]'); - - expect(titles).toHaveLength(3); - expect(descriptions).toHaveLength(3); - - expect(titles[0].text()).toBe('Create Device Password'); - expect(titles[1].text()).toBe('Configure Basic Settings'); - expect(titles[2].text()).toBe('Unleash Your Hardware'); - }); -}); diff --git a/web/components.d.ts b/web/components.d.ts index f7c7e0f21..5fa6f9a87 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -154,6 +154,7 @@ declare module 'vue' { USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default'] 'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default'] USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default'] + UStepper: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Stepper.vue')['default'] USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default'] UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default'] UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default'] diff --git a/web/public/test-pages/shared-header.js b/web/public/test-pages/shared-header.js index 6f2588b03..dfbdde20e 100644 --- a/web/public/test-pages/shared-header.js +++ b/web/public/test-pages/shared-header.js @@ -19,6 +19,15 @@
+
+ + +