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 @@
+
+ + +