diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index b09f90470..f4f76b8f5 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -5,6 +5,5 @@ "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 cf9fce3b3..a9d9ccffa 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -781,6 +781,70 @@ type Settings implements Node { api: ApiConfig! } +type CoreVersions { + """Unraid version""" + unraid: String + + """Unraid API version""" + api: String + + """Kernel version""" + kernel: String +} + +type PackageVersions { + """OpenSSL version""" + openssl: String + + """Node.js version""" + node: String + + """npm version""" + npm: String + + """pm2 version""" + pm2: String + + """Git version""" + git: String + + """nginx version""" + nginx: String + + """PHP version""" + php: String + + """Docker version""" + 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 + + """Onboarding step identifiers completed for the current OS version""" + completedSteps: [String!]! +} + +type InfoVersions implements Node { + id: PrefixedID! + + """Core system versions""" + core: CoreVersions! + + """Software package versions""" + packages: PackageVersions + + """OS upgrade information""" + upgrade: UpgradeInfo! +} + type RCloneDrive { """Provider name""" name: String! @@ -1029,6 +1093,20 @@ input DeleteRCloneRemoteInput { name: String! } +"""Onboarding related mutations""" +type OnboardingMutations { + """ + Mark an upgrade onboarding step as completed for the current OS version + """ + completeUpgradeStep(input: CompleteUpgradeStepInput!): UpgradeInfo! +} + +"""Input for marking an upgrade onboarding step as completed""" +input CompleteUpgradeStepInput { + """Identifier of the onboarding step to mark completed""" + stepId: String! +} + type Config implements Node { id: PrefixedID! valid: Boolean @@ -1927,67 +2005,6 @@ type InfoBaseboard implements Node { memSlots: Float } -type CoreVersions { - """Unraid version""" - unraid: String - - """Unraid API version""" - api: String - - """Kernel version""" - kernel: String -} - -type PackageVersions { - """OpenSSL version""" - openssl: String - - """Node.js version""" - node: String - - """npm version""" - npm: String - - """pm2 version""" - pm2: String - - """Git version""" - git: String - - """nginx version""" - nginx: String - - """PHP version""" - php: String - - """Docker version""" - 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! - - """Core system versions""" - core: CoreVersions! - - """Software package versions""" - packages: PackageVersions - - """OS upgrade information""" - upgrade: UpgradeInfo! -} - type Info implements Node { id: PrefixedID! @@ -2709,6 +2726,7 @@ type Mutation { apiKey: ApiKeyMutations! customization: CustomizationMutations! rclone: RCloneMutations! + onboarding: OnboardingMutations! createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1! setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1! diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index a96c58ee7..3e1e72c9d 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -434,6 +434,12 @@ export type CloudResponse = { status: Scalars['String']['output']; }; +/** Input for marking an upgrade onboarding step as completed */ +export type CompleteUpgradeStepInput = { + /** Identifier of the onboarding step to mark completed */ + stepId: Scalars['String']['input']; +}; + export type Config = Node & { __typename?: 'Config'; error?: Maybe; @@ -1464,6 +1470,7 @@ export type Mutation = { moveDockerItemsToPosition: ResolvedOrganizerV1; /** Creates a notification if an equivalent unread notification does not already exist. */ notifyIfUnique?: Maybe; + onboarding: OnboardingMutations; parityCheck: ParityCheckMutations; rclone: RCloneMutations; /** Reads each notification to recompute & update the overview. */ @@ -1774,6 +1781,19 @@ export type OidcSessionValidation = { valid: Scalars['Boolean']['output']; }; +/** Onboarding related mutations */ +export type OnboardingMutations = { + __typename?: 'OnboardingMutations'; + /** Mark an upgrade onboarding step as completed for the current OS version */ + completeUpgradeStep: UpgradeInfo; +}; + + +/** Onboarding related mutations */ +export type OnboardingMutationsCompleteUpgradeStepArgs = { + input: CompleteUpgradeStepInput; +}; + export type Owner = { __typename?: 'Owner'; avatar: Scalars['String']['output']; @@ -2614,6 +2634,8 @@ export type UpdateSystemTimeInput = { export type UpgradeInfo = { __typename?: 'UpgradeInfo'; + /** Onboarding step identifiers completed for the current OS version */ + completedSteps: Array; /** Current OS version */ currentVersion?: Maybe; /** Whether the OS version has changed since last boot */ diff --git a/api/src/unraid-api/config/api-config.module.ts b/api/src/unraid-api/config/api-config.module.ts index ce182ad55..97c47b587 100644 --- a/api/src/unraid-api/config/api-config.module.ts +++ b/api/src/unraid-api/config/api-config.module.ts @@ -8,7 +8,7 @@ import { csvStringToArray } from '@unraid/shared/util/data.js'; import { isConnectPluginInstalled } from '@app/connect-plugin-cleanup.js'; import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js'; -import { OsVersionTrackerModule } from '@app/unraid-api/config/os-version-tracker.module.js'; +import { OnboardingTrackerModule } from '@app/unraid-api/config/onboarding-tracker.module.js'; export { type ApiConfig }; @@ -120,8 +120,8 @@ export class ApiConfigPersistence // apiConfig should be registered in root config in app.module.ts, not here. @Module({ - imports: [OsVersionTrackerModule], + imports: [OnboardingTrackerModule], providers: [ApiConfigPersistence], - exports: [ApiConfigPersistence, OsVersionTrackerModule], + exports: [ApiConfigPersistence, OnboardingTrackerModule], }) export class ApiConfigModule {} diff --git a/api/src/unraid-api/config/api-config.test.ts b/api/src/unraid-api/config/api-config.test.ts index bcd7d143e..fe60fb262 100644 --- a/api/src/unraid-api/config/api-config.test.ts +++ b/api/src/unraid-api/config/api-config.test.ts @@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { API_VERSION, PATHS_CONFIG_MODULES } from '@app/environment.js'; import { ApiConfigPersistence, loadApiConfig } from '@app/unraid-api/config/api-config.module.js'; -import { OsVersionTracker } from '@app/unraid-api/config/os-version-tracker.module.js'; +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; vi.mock('@app/core/utils/files/file-exists.js', () => ({ fileExists: vi.fn(), @@ -104,16 +104,20 @@ describe('ApiConfigPersistence', () => { }); }); -describe('OsVersionTracker', () => { - const trackerPath = path.join(PATHS_CONFIG_MODULES, 'os-version-tracker.json'); +describe('OnboardingTracker', () => { + const trackerPath = path.join(PATHS_CONFIG_MODULES, 'onboarding-tracker.json'); let configService: ConfigService; let setMock: ReturnType; + let configStore: Record; beforeEach(() => { - setMock = vi.fn(); + configStore = {}; + setMock = vi.fn((key: string, value: unknown) => { + configStore[key] = value; + }); configService = { set: setMock, - get: vi.fn(), + get: vi.fn((key: string) => configStore[key]), getOrThrow: vi.fn(), } as any; @@ -121,7 +125,7 @@ describe('OsVersionTracker', () => { mockAtomicWriteFile.mockReset(); }); - it('persists current version when tracker is missing', async () => { + it('defers persisting last seen version until shutdown', async () => { mockReadFile.mockImplementation(async (filePath) => { if (filePath === '/etc/unraid-version') { return 'version="7.2.0-beta.3.4"\n'; @@ -129,12 +133,17 @@ describe('OsVersionTracker', () => { throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - const tracker = new OsVersionTracker(configService); + const tracker = new OnboardingTracker(configService); await tracker.onApplicationBootstrap(); - expect(setMock).toHaveBeenCalledWith('api.currentOsVersion', '7.2.0-beta.3.4'); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', '7.2.0-beta.3.4'); expect(setMock).toHaveBeenCalledWith('store.emhttp.var.version', '7.2.0-beta.3.4'); - expect(setMock).toHaveBeenCalledWith('api.lastSeenOsVersion', undefined); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', undefined); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.completedSteps', {}); + expect(configStore['api.lastSeenOsVersion']).toBeUndefined(); + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + + await tracker.onApplicationShutdown(); expect(mockAtomicWriteFile).toHaveBeenCalledWith( trackerPath, @@ -152,30 +161,130 @@ describe('OsVersionTracker', () => { return JSON.stringify({ lastTrackedVersion: '6.12.0', updatedAt: '2024-01-01T00:00:00.000Z', + completedSteps: { + timezone: { + version: '6.12.0', + completedAt: '2024-01-02T00:00:00.000Z', + }, + }, }); } throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); }); - const tracker = new OsVersionTracker(configService); + const tracker = new OnboardingTracker(configService); await tracker.onApplicationBootstrap(); - expect(setMock).toHaveBeenCalledWith('api.currentOsVersion', '6.12.0'); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', '6.12.0'); expect(setMock).toHaveBeenCalledWith('store.emhttp.var.version', '6.12.0'); - expect(setMock).toHaveBeenCalledWith('api.lastSeenOsVersion', '6.12.0'); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', '6.12.0'); + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.completedSteps', + expect.objectContaining({ + timezone: expect.objectContaining({ version: '6.12.0' }), + }) + ); + expect(configStore['api.lastSeenOsVersion']).toBe('6.12.0'); + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + + await tracker.onApplicationShutdown(); + + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + }); + + it('keeps previous version available to signal upgrade until shutdown', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === '/etc/unraid-version') { + return 'version="7.1.0"\n'; + } + if (filePath === trackerPath) { + return JSON.stringify({ + lastTrackedVersion: '7.0.0', + updatedAt: '2025-01-01T00:00:00.000Z', + completedSteps: {}, + }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + await tracker.onApplicationBootstrap(); + + const snapshot = tracker.getUpgradeSnapshot(); + expect(snapshot.currentVersion).toBe('7.1.0'); + expect(snapshot.lastTrackedVersion).toBe('7.0.0'); + expect(snapshot.completedSteps).toEqual([]); + + expect(configStore['onboardingTracker.lastTrackedVersion']).toBe('7.0.0'); + expect(configStore['store.emhttp.var.version']).toBe('7.1.0'); + expect(configStore['onboardingTracker.completedSteps']).toEqual({}); + expect(configStore['api.lastSeenOsVersion']).toBe('7.0.0'); + expect(mockAtomicWriteFile).not.toHaveBeenCalled(); }); it('handles missing version file gracefully', async () => { mockReadFile.mockRejectedValue(new Error('permission denied')); - const tracker = new OsVersionTracker(configService); + const tracker = new OnboardingTracker(configService); await tracker.onApplicationBootstrap(); - expect(setMock).toHaveBeenCalledWith('api.currentOsVersion', undefined); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.currentVersion', undefined); expect(setMock).toHaveBeenCalledWith('store.emhttp.var.version', undefined); - expect(setMock).toHaveBeenCalledWith('api.lastSeenOsVersion', undefined); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', undefined); + expect(setMock).toHaveBeenCalledWith('onboardingTracker.completedSteps', {}); expect(mockAtomicWriteFile).not.toHaveBeenCalled(); + expect(configStore['api.lastSeenOsVersion']).toBeUndefined(); + }); + + it('marks onboarding steps complete for the current version without clearing upgrade flag', async () => { + mockReadFile.mockImplementation(async (filePath) => { + if (filePath === '/etc/unraid-version') { + return 'version="7.2.0"\n'; + } + if (filePath === trackerPath) { + return JSON.stringify({ + lastTrackedVersion: '6.12.0', + updatedAt: '2025-01-01T00:00:00.000Z', + completedSteps: {}, + }); + } + throw Object.assign(new Error('Not found'), { code: 'ENOENT' }); + }); + + const tracker = new OnboardingTracker(configService); + await tracker.onApplicationBootstrap(); + + expect(configStore['store.emhttp.var.version']).toBe('7.2.0'); + expect(configStore['onboardingTracker.lastTrackedVersion']).toBe('6.12.0'); + expect(configStore['api.lastSeenOsVersion']).toBe('6.12.0'); + + setMock.mockClear(); + mockAtomicWriteFile.mockReset(); + + const snapshot = await tracker.markStepCompleted('timezone'); + + expect(snapshot.currentVersion).toBe('7.2.0'); + expect(snapshot.completedSteps).toContain('timezone'); + expect(snapshot.lastTrackedVersion).toBe('6.12.0'); + + expect(mockAtomicWriteFile).toHaveBeenCalledWith( + trackerPath, + expect.stringContaining('"timezone"'), + { mode: 0o644 } + ); + + expect(setMock).toHaveBeenCalledWith( + 'onboardingTracker.completedSteps', + expect.objectContaining({ + timezone: expect.objectContaining({ version: '7.2.0' }), + }) + ); + + expect(setMock).toHaveBeenCalledWith('onboardingTracker.lastTrackedVersion', '6.12.0'); + + const postSnapshot = tracker.getUpgradeSnapshot(); + expect(postSnapshot.lastTrackedVersion).toBe('6.12.0'); }); }); diff --git a/api/src/unraid-api/config/onboarding-tracker.module.ts b/api/src/unraid-api/config/onboarding-tracker.module.ts new file mode 100644 index 000000000..0831abf3d --- /dev/null +++ b/api/src/unraid-api/config/onboarding-tracker.module.ts @@ -0,0 +1,224 @@ +import { + Injectable, + Logger, + Module, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { readFile } from 'fs/promises'; +import path from 'path'; + +import { writeFile } from 'atomically'; + +import { PATHS_CONFIG_MODULES } from '@app/environment.js'; + +const TRACKER_FILE_NAME = 'onboarding-tracker.json'; +const LEGACY_TRACKER_FILE_NAME = 'os-version-tracker.json'; +const CONFIG_PREFIX = 'onboardingTracker'; +const OS_VERSION_FILE_PATH = '/etc/unraid-version'; + +type CompletedStepState = { + version: string; + completedAt: string; +}; + +type TrackerState = { + lastTrackedVersion?: string; + updatedAt?: string; + completedSteps?: Record; +}; + +export type UpgradeProgressSnapshot = { + currentVersion?: string; + lastTrackedVersion?: string; + completedSteps: string[]; +}; + +@Injectable() +export class OnboardingTracker implements OnApplicationBootstrap, OnApplicationShutdown { + private readonly logger = new Logger(OnboardingTracker.name); + private readonly trackerPath = path.join(PATHS_CONFIG_MODULES, TRACKER_FILE_NAME); + private readonly legacyTrackerPath = path.join(PATHS_CONFIG_MODULES, LEGACY_TRACKER_FILE_NAME); + private state: TrackerState = {}; + private sessionLastTrackedVersion?: string; + private currentVersion?: string; + + constructor(private readonly configService: ConfigService) {} + + async onApplicationBootstrap() { + this.currentVersion = await this.readCurrentVersion(); + if (!this.currentVersion) { + this.state = {}; + this.sessionLastTrackedVersion = undefined; + this.syncConfig(undefined); + return; + } + + const previousState = await this.readTrackerState(); + this.state = previousState ?? {}; + this.sessionLastTrackedVersion = previousState?.lastTrackedVersion; + + this.syncConfig(this.currentVersion); + } + + async onApplicationShutdown() { + if (!this.currentVersion) { + return; + } + + await this.ensureStateLoaded(); + if (this.state.lastTrackedVersion === this.currentVersion) { + return; + } + + const updatedState: TrackerState = { + ...this.state, + lastTrackedVersion: this.currentVersion, + updatedAt: new Date().toISOString(), + }; + + await this.writeTrackerState(updatedState); + this.sessionLastTrackedVersion = this.currentVersion; + } + + getUpgradeSnapshot(): UpgradeProgressSnapshot { + const currentVersion = + this.configService.get(`${CONFIG_PREFIX}.currentVersion`) ?? + this.configService.get('store.emhttp.var.version') ?? + undefined; + + const lastTrackedVersion = + this.configService.get(`${CONFIG_PREFIX}.lastTrackedVersion`) ?? undefined; + + const completedSteps = + currentVersion && this.state.completedSteps + ? this.completedStepsForVersion(currentVersion) + : []; + + return { + currentVersion, + lastTrackedVersion, + completedSteps, + }; + } + + async markStepCompleted(stepId: string): Promise { + const currentVersion = + this.configService.get(`${CONFIG_PREFIX}.currentVersion`) ?? + this.configService.get('store.emhttp.var.version') ?? + undefined; + + if (!currentVersion) { + this.logger.warn( + `Unable to mark onboarding step '${stepId}' as completed; current OS version unknown` + ); + return this.getUpgradeSnapshot(); + } + + await this.ensureStateLoaded(); + const completedSteps = this.state.completedSteps ?? {}; + const existing = completedSteps[stepId]; + + if (existing?.version === currentVersion) { + return this.getUpgradeSnapshot(); + } + + completedSteps[stepId] = { + version: currentVersion, + completedAt: new Date().toISOString(), + }; + + const updatedState: TrackerState = { + ...this.state, + completedSteps, + updatedAt: new Date().toISOString(), + }; + + await this.writeTrackerState(updatedState); + this.syncConfig(currentVersion); + + return this.getUpgradeSnapshot(); + } + + private async ensureStateLoaded() { + if (Object.keys(this.state).length > 0) { + return; + } + this.state = (await this.readTrackerState()) ?? {}; + } + + private completedStepsForVersion(version: string): string[] { + const completedEntries = this.state.completedSteps ?? {}; + return Object.entries(completedEntries) + .filter(([, value]) => value?.version === version) + .map(([stepId]) => stepId); + } + + private syncConfig(currentVersion: string | undefined) { + const completedStepsMap = this.state.completedSteps ?? {}; + this.configService.set(`${CONFIG_PREFIX}.currentVersion`, currentVersion); + this.configService.set('store.emhttp.var.version', currentVersion); + this.configService.set(`${CONFIG_PREFIX}.lastTrackedVersion`, this.sessionLastTrackedVersion); + this.configService.set(`${CONFIG_PREFIX}.completedSteps`, completedStepsMap); + this.configService.set('api.lastSeenOsVersion', this.sessionLastTrackedVersion); + } + + private async readCurrentVersion(): Promise { + try { + const contents = await readFile(OS_VERSION_FILE_PATH, 'utf8'); + const match = contents.match(/^\s*version\s*=\s*"([^"]+)"\s*$/m); + return match?.[1]?.trim() || undefined; + } catch (error) { + this.logger.error(error, `Failed to read current OS version from ${OS_VERSION_FILE_PATH}`); + return undefined; + } + } + + private async readTrackerState(): Promise { + try { + const content = await readFile(this.trackerPath, 'utf8'); + return JSON.parse(content) as TrackerState; + } catch (error) { + this.logger.debug(error, `Unable to read onboarding tracker state at ${this.trackerPath}`); + + const legacyState = await this.readLegacyTrackerState(); + if (legacyState) { + return legacyState; + } + + return undefined; + } + } + + private async readLegacyTrackerState(): Promise { + try { + const content = await readFile(this.legacyTrackerPath, 'utf8'); + this.logger.log( + `Loaded legacy onboarding tracker state from ${LEGACY_TRACKER_FILE_NAME}; will persist to ${TRACKER_FILE_NAME}` + ); + return JSON.parse(content) as TrackerState; + } catch (error) { + this.logger.debug( + error, + `Unable to read legacy onboarding tracker state at ${this.legacyTrackerPath}` + ); + return undefined; + } + } + + private async writeTrackerState(state: TrackerState): Promise { + try { + await writeFile(this.trackerPath, JSON.stringify(state, null, 2), { mode: 0o644 }); + this.state = state; + } catch (error) { + this.logger.error(error, 'Failed to persist onboarding tracker state'); + } + } +} + +@Module({ + providers: [OnboardingTracker], + exports: [OnboardingTracker], +}) +export class OnboardingTrackerModule {} diff --git a/api/src/unraid-api/config/os-version-tracker.module.ts b/api/src/unraid-api/config/os-version-tracker.module.ts deleted file mode 100644 index c0c1f7e10..000000000 --- a/api/src/unraid-api/config/os-version-tracker.module.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Injectable, Logger, Module, OnApplicationBootstrap } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { readFile } from 'fs/promises'; -import path from 'path'; - -import { writeFile } from 'atomically'; - -import { PATHS_CONFIG_MODULES } from '@app/environment.js'; - -const OS_VERSION_FILE_PATH = '/etc/unraid-version'; -const VERSION_TRACKER_FILE = 'os-version-tracker.json'; - -type OsVersionTrackerState = { - lastTrackedVersion?: string; - updatedAt?: string; -}; - -@Injectable() -export class OsVersionTracker implements OnApplicationBootstrap { - private readonly logger = new Logger(OsVersionTracker.name); - private readonly trackerPath = path.join(PATHS_CONFIG_MODULES, VERSION_TRACKER_FILE); - - constructor(private readonly configService: ConfigService) {} - - async onApplicationBootstrap() { - const currentVersion = await this.readCurrentVersion(); - if (!currentVersion) { - this.configService.set('api.currentOsVersion', undefined); - this.configService.set('store.emhttp.var.version', undefined); - this.configService.set('api.lastSeenOsVersion', undefined); - return; - } - - const previousState = await this.readTrackerState(); - const lastTrackedVersion = previousState?.lastTrackedVersion; - - this.configService.set('api.currentOsVersion', currentVersion); - this.configService.set('store.emhttp.var.version', currentVersion); - this.configService.set('api.lastSeenOsVersion', lastTrackedVersion); - - if (lastTrackedVersion !== currentVersion) { - await this.writeTrackerState({ - lastTrackedVersion: currentVersion, - updatedAt: new Date().toISOString(), - }); - } - } - - private async readCurrentVersion(): Promise { - try { - const contents = await readFile(OS_VERSION_FILE_PATH, 'utf8'); - const match = contents.match(/^\s*version\s*=\s*"([^"]+)"\s*$/m); - return match?.[1]?.trim() || undefined; - } catch (error) { - this.logger.error(error, `Failed to read current OS version from ${OS_VERSION_FILE_PATH}`); - return undefined; - } - } - - private async readTrackerState(): Promise { - try { - const content = await readFile(this.trackerPath, 'utf8'); - return JSON.parse(content) as OsVersionTrackerState; - } catch (error) { - this.logger.debug(error, `Unable to read OS version tracker state at ${this.trackerPath}`); - return undefined; - } - } - - private async writeTrackerState(state: OsVersionTrackerState): Promise { - try { - await writeFile(this.trackerPath, JSON.stringify(state, null, 2), { mode: 0o644 }); - } catch (error) { - this.logger.error(error, 'Failed to persist OS version tracker state'); - } - } -} - -@Module({ - providers: [OsVersionTracker], - exports: [OsVersionTracker], -}) -export class OsVersionTrackerModule {} 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 d8fbd3b96..c2da35ef9 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 @@ -51,6 +51,12 @@ export class UpgradeInfo { @Field(() => String, { nullable: true, description: 'Current OS version' }) currentVersion?: string; + + @Field(() => [String], { + description: 'Onboarding step identifiers completed for the current OS version', + defaultValue: [], + }) + completedSteps!: string[]; } @ObjectType({ implements: () => Node }) 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 f8ac99c9f..97a0c8846 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 @@ -49,8 +49,23 @@ export class VersionsResolver { @ResolveField(() => UpgradeInfo) upgrade(): UpgradeInfo { - const currentVersion = this.configService.get('store.emhttp.var.version'); - const lastSeenVersion = this.configService.get('api.lastSeenOsVersion'); + const currentVersion = + this.configService.get('onboardingTracker.currentVersion') ?? + this.configService.get('store.emhttp.var.version'); + const lastSeenVersion = + this.configService.get('onboardingTracker.lastTrackedVersion') ?? + this.configService.get('api.lastSeenOsVersion'); + const completedStepsMap = + this.configService.get>( + 'onboardingTracker.completedSteps' + ) ?? {}; + + const completedSteps = + currentVersion && completedStepsMap + ? Object.entries(completedStepsMap) + .filter(([, value]) => value?.version === currentVersion) + .map(([stepId]) => stepId) + : []; const isUpgrade = Boolean( lastSeenVersion && currentVersion && lastSeenVersion !== currentVersion @@ -60,6 +75,7 @@ export class VersionsResolver { isUpgrade, previousVersion: isUpgrade ? lastSeenVersion : undefined, currentVersion: currentVersion || undefined, + completedSteps, }; } } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index aae73aeeb..840343780 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -1,5 +1,6 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { UpgradeInfo } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js'; import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js'; /** @@ -45,6 +46,16 @@ export class RCloneMutations { deleteRCloneRemote!: boolean; } +@ObjectType({ + description: 'Onboarding related mutations', +}) +export class OnboardingMutations { + @Field(() => UpgradeInfo, { + description: 'Mark an upgrade onboarding step as completed for the current OS version', + }) + completeUpgradeStep!: UpgradeInfo; +} + @ObjectType() export class RootMutations { @Field(() => ArrayMutations, { description: 'Array related mutations' }) @@ -67,4 +78,7 @@ export class RootMutations { @Field(() => RCloneMutations, { description: 'RClone related mutations' }) rclone: RCloneMutations = new RCloneMutations(); + + @Field(() => OnboardingMutations, { description: 'Onboarding related mutations' }) + onboarding: OnboardingMutations = new OnboardingMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 7beca48bc..a32e83dd9 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -5,6 +5,7 @@ import { ArrayMutations, CustomizationMutations, DockerMutations, + OnboardingMutations, ParityCheckMutations, RCloneMutations, RootMutations, @@ -47,4 +48,9 @@ export class RootMutationsResolver { rclone(): RCloneMutations { return new RCloneMutations(); } + + @Mutation(() => OnboardingMutations, { name: 'onboarding' }) + onboarding(): OnboardingMutations { + return new OnboardingMutations(); + } } diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts new file mode 100644 index 000000000..c88cd15e9 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -0,0 +1,9 @@ +import { Field, InputType } from '@nestjs/graphql'; + +@InputType({ + description: 'Input for marking an upgrade onboarding step as completed', +}) +export class CompleteUpgradeStepInput { + @Field(() => String, { description: 'Identifier of the onboarding step to mark completed' }) + stepId!: string; +} diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts new file mode 100644 index 000000000..2290f88a4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -0,0 +1,54 @@ +import { ConfigService } from '@nestjs/config'; +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { OnboardingTracker } from '@app/unraid-api/config/onboarding-tracker.module.js'; +import { UpgradeInfo } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js'; +import { OnboardingMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { CompleteUpgradeStepInput } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.model.js'; + +@Resolver(() => OnboardingMutations) +export class OnboardingMutationsResolver { + constructor( + private readonly onboardingTracker: OnboardingTracker, + private readonly configService: ConfigService + ) {} + + @ResolveField(() => UpgradeInfo, { + description: 'Marks an upgrade onboarding step as completed for the current OS version', + }) + async completeUpgradeStep(@Args('input') input: CompleteUpgradeStepInput): Promise { + await this.onboardingTracker.markStepCompleted(input.stepId); + return this.buildUpgradeInfo(); + } + + private buildUpgradeInfo(): UpgradeInfo { + const currentVersion = + this.configService.get('onboardingTracker.currentVersion') ?? + this.configService.get('store.emhttp.var.version'); + const lastSeenVersion = + this.configService.get('onboardingTracker.lastTrackedVersion') ?? + this.configService.get('api.lastSeenOsVersion'); + const completedStepsMap = + this.configService.get>( + 'onboardingTracker.completedSteps' + ) ?? {}; + + const completedSteps = + currentVersion && completedStepsMap + ? Object.entries(completedStepsMap) + .filter(([, value]) => value?.version === currentVersion) + .map(([stepId]) => stepId) + : []; + + const isUpgrade = Boolean( + lastSeenVersion && currentVersion && lastSeenVersion !== currentVersion + ); + + return { + isUpgrade, + previousVersion: isUpgrade ? lastSeenVersion : undefined, + currentVersion: currentVersion || undefined, + completedSteps, + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 080204e91..84a62616f 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -17,6 +17,7 @@ import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.m import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; +import { OnboardingMutationsResolver } from '@app/unraid-api/graph/resolvers/onboarding/onboarding.mutation.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; @@ -63,6 +64,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; NotificationsResolver, OnlineResolver, OwnerResolver, + OnboardingMutationsResolver, RegistrationResolver, RootMutationsResolver, ServerResolver, diff --git a/web/src/components/Activation/ActivationModal.vue b/web/src/components/Activation/ActivationModal.vue index 24d68aac2..9044ca86a 100644 --- a/web/src/components/Activation/ActivationModal.vue +++ b/web/src/components/Activation/ActivationModal.vue @@ -1,6 +1,7 @@