diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 4d75727a43..3ee5824e2f 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -2697,6 +2697,9 @@ type Server implements Node { lanip: String! localurl: String! remoteurl: String! + + """Preferred live URL from nginx.ini defaultUrl""" + defaultUrl: String } enum ServerStatus { diff --git a/api/src/unraid-api/avahi/avahi.service.ts b/api/src/unraid-api/avahi/avahi.service.ts new file mode 100644 index 0000000000..fae5c38f84 --- /dev/null +++ b/api/src/unraid-api/avahi/avahi.service.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { execa } from 'execa'; + +@Injectable() +export class AvahiService { + private readonly logger = new Logger(AvahiService.name); + + async restart(): Promise { + try { + await execa('/etc/rc.d/rc.avahidaemon', ['restart'], { + timeout: 10_000, + }); + this.logger.log('Avahi daemon restarted'); + } catch (error) { + this.logger.error('Failed to restart Avahi daemon', error as Error); + throw error; + } + } +} diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index f16778e606..2f0e2335c1 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -2685,6 +2685,8 @@ export type Server = Node & { apikey: Scalars['String']['output']; /** Server description/comment */ comment?: Maybe; + /** Preferred live URL from nginx.ini defaultUrl */ + defaultUrl?: Maybe; guid: Scalars['String']['output']; id: Scalars['PrefixedID']['output']; lanip: Scalars['String']['output']; diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts index b28d3744e0..a3115ff475 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.spec.ts @@ -2005,5 +2005,26 @@ describe('OnboardingService - updateCfgFile', () => { }, }); }); + + it('skips saving the onboarding draft when the expected server name does not match the live identity', async () => { + vi.spyOn(getters, 'emhttp').mockReturnValue({ + ...getters.emhttp(), + nginx: { + ...getters.emhttp().nginx, + lanName: 'Tower', + }, + }); + + await expect( + service.saveOnboardingDraft({ + navigation: { + currentStepId: OnboardingWizardStepId.NEXT_STEPS, + }, + expectedServerName: 'Cheese', + }) + ).resolves.toBe(false); + + expect(onboardingTrackerMock.saveDraft).not.toHaveBeenCalled(); + }); }); }); diff --git a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts index b930310db4..ad12e613e7 100644 --- a/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts +++ b/api/src/unraid-api/graph/resolvers/customization/onboarding.service.ts @@ -532,7 +532,19 @@ export class OnboardingService implements OnModuleInit { this.onboardingTracker.setBypassActive(false); } - public async saveOnboardingDraft(input: SaveOnboardingDraftInput): Promise { + public async saveOnboardingDraft(input: SaveOnboardingDraftInput): Promise { + if (input.expectedServerName) { + // Rename flows use this guard to avoid persisting a resume step until + // the live server identity has actually switched to the expected host. + const liveServerName = getters.emhttp().nginx?.lanName?.trim() ?? ''; + if (liveServerName !== input.expectedServerName.trim()) { + this.logger.warn( + `Skipping onboarding draft save because live server name '${liveServerName}' did not match expected '${input.expectedServerName}'.` + ); + return false; + } + } + await this.onboardingTracker.saveDraft({ draft: input.draft, navigation: input.navigation @@ -547,6 +559,7 @@ export class OnboardingService implements OnModuleInit { } : undefined, }); + return true; } public isFreshInstall(): boolean { diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts index 2859794dc6..4e38054895 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.model.ts @@ -316,6 +316,11 @@ export class SaveOnboardingDraftInput { @IsOptional() draft?: OnboardingDraft; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + expectedServerName?: string; + @Field(() => OnboardingWizardNavigationInput, { nullable: true }) @IsOptional() @ValidateNested() diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts index 2d3ac77591..cca783aa38 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.spec.ts @@ -92,7 +92,7 @@ describe('OnboardingMutationsResolver', () => { onboardingService.closeOnboarding.mockResolvedValue(undefined); onboardingService.bypassOnboarding.mockResolvedValue(undefined); onboardingService.resumeOnboarding.mockResolvedValue(undefined); - onboardingService.saveOnboardingDraft.mockResolvedValue(undefined); + onboardingService.saveOnboardingDraft.mockResolvedValue(true); onboardingService.getOnboardingResponse.mockResolvedValue(defaultOnboardingResponse); resolver = createResolver(); diff --git a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts index 837c8f602f..565ce59024 100644 --- a/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts +++ b/api/src/unraid-api/graph/resolvers/onboarding/onboarding.mutation.ts @@ -137,8 +137,7 @@ export class OnboardingMutationsResolver { resource: Resource.WELCOME, }) async saveOnboardingDraft(@Args('input') input: SaveOnboardingDraftInput): Promise { - await this.onboardingService.saveOnboardingDraft(input); - return true; + return this.onboardingService.saveOnboardingDraft(input); } @ResolveField(() => OnboardingInternalBootResult, { diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index f912fea200..b65a66ca32 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ApiConfigModule } from '@app/unraid-api/config/api-config.module.js'; import { OnboardingOverrideModule } from '@app/unraid-api/config/onboarding-override.module.js'; import { OnboardingStateModule } from '@app/unraid-api/config/onboarding-state.module.js'; @@ -83,6 +84,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; RootMutationsResolver, ServerResolver, ServerService, + AvahiService, ServicesResolver, SharesResolver, VarsResolver, diff --git a/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts b/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts new file mode 100644 index 0000000000..d4c44ebb79 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/servers/build-server-response.ts @@ -0,0 +1,47 @@ +import { type SliceState } from '@app/store/modules/emhttp.js'; +import { + ProfileModel, + Server, + ServerStatus, +} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; + +type BuildServerResponseOptions = { + apikey?: string; + comment?: string; + name?: string; + owner?: Partial; +}; + +const DEFAULT_OWNER: ProfileModel = { + id: 'local', + username: 'root', + url: '', + avatar: '', +}; + +export const buildServerResponse = ( + emhttpState: SliceState, + { apikey = '', comment, name, owner }: BuildServerResponseOptions = {} +): Server => { + const lanip = emhttpState.networks?.[0]?.ipaddr?.[0] ?? ''; + const port = emhttpState.var?.port ?? ''; + const defaultUrl = emhttpState.nginx?.defaultUrl?.trim() || undefined; + + return { + id: 'local', + owner: { + ...DEFAULT_OWNER, + ...owner, + }, + guid: emhttpState.var?.regGuid ?? '', + apikey, + name: name ?? emhttpState.var?.name ?? 'Local Server', + comment: comment ?? emhttpState.var?.comment, + status: ServerStatus.ONLINE, + wanip: '', + lanip, + localurl: lanip ? `http://${lanip}${port ? `:${port}` : ''}` : '', + remoteurl: '', + defaultUrl, + }; +}; diff --git a/api/src/unraid-api/graph/resolvers/servers/server.model.ts b/api/src/unraid-api/graph/resolvers/servers/server.model.ts index 0b6ddc3c70..e81214dab6 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.model.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.model.ts @@ -57,4 +57,7 @@ export class Server extends Node { @Field() remoteurl!: string; + + @Field({ nullable: true, description: 'Preferred live URL from nginx.ini defaultUrl' }) + defaultUrl?: string; } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts index 4018bfe9a5..426347bbeb 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.spec.ts @@ -4,17 +4,44 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getters } from '@app/store/index.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, +})); + describe('ServersResolver', () => { let resolver: ServerResolver; + let mockConfigService: { get: ReturnType }; beforeEach(async () => { - const mockConfigService = { + mockConfigService = { get: vi.fn(), }; + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + regGuid: 'GUID-123', + name: 'Tower', + comment: 'Primary host', + port: 80, + }, + networks: [{ ipaddr: ['192.168.1.10'] }], + nginx: { + defaultUrl: 'https://Tower.local:4443', + }, + } as ReturnType); + mockConfigService.get.mockReturnValue({ + config: { + username: 'ajit', + apikey: 'api-key-123', + }, + }); + const module: TestingModule = await Test.createTestingModule({ providers: [ ServerResolver, @@ -35,4 +62,26 @@ describe('ServersResolver', () => { it('should be defined', () => { expect(resolver).toBeDefined(); }); + + it('returns the shared server shape with defaultUrl for the server query', async () => { + await expect(resolver.server()).resolves.toEqual({ + id: 'local', + owner: { + id: 'local', + username: 'ajit', + url: '', + avatar: '', + }, + guid: 'GUID-123', + apikey: 'api-key-123', + name: 'Tower', + comment: 'Primary host', + status: 'ONLINE', + wanip: '', + lanip: '192.168.1.10', + localurl: 'http://192.168.1.10:80', + remoteurl: '', + defaultUrl: 'https://Tower.local:4443', + }); + }); }); diff --git a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts index 65f041a560..5edfb465ee 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.resolver.ts @@ -8,11 +8,8 @@ import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { MinigraphStatus } from '@app/unraid-api/graph/resolvers/cloud/cloud.model.js'; -import { - ProfileModel, - Server as ServerModel, - ServerStatus, -} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; +import { buildServerResponse } from '@app/unraid-api/graph/resolvers/servers/build-server-response.js'; +import { Server as ServerModel } from '@app/unraid-api/graph/resolvers/servers/server.model.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; @Injectable() @@ -65,35 +62,11 @@ export class ServerResolver { private getLocalServer(): ServerModel { const emhttp = getters.emhttp(); const connectConfig = this.configService.get('connect'); - - const guid = emhttp.var.regGuid; - const name = emhttp.var.name; - const comment = emhttp.var.comment; - const wanip = ''; - const lanip: string = emhttp.networks[0]?.ipaddr[0] || ''; - const port = emhttp.var?.port; - const localurl = `http://${lanip}:${port}`; - const remoteurl = ''; - - const owner: ProfileModel = { - id: 'local', - username: connectConfig?.config?.username ?? 'root', - url: '', - avatar: '', - }; - - return { - id: 'local', - owner, - guid: guid || '', + return buildServerResponse(emhttp, { apikey: connectConfig?.config?.apikey ?? '', - name: name ?? 'Local Server', - comment, - status: ServerStatus.ONLINE, - wanip, - lanip, - localurl, - remoteurl, - }; + owner: { + username: connectConfig?.config?.username ?? 'root', + }, + }); } } diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts index 2fc2f1e287..683b89da13 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.spec.ts @@ -6,9 +6,10 @@ import { GraphQLError } from 'graphql'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { getters } from '@app/store/index.js'; +import { getters, store } from '@app/store/index.js'; import { type SliceState } from '@app/store/modules/emhttp.js'; import { FileLoadStatus } from '@app/store/types.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; @@ -21,6 +22,9 @@ vi.mock('@app/store/index.js', () => ({ emhttp: vi.fn(), paths: vi.fn(), }, + store: { + dispatch: vi.fn(), + }, })); const createEmhttpState = ({ @@ -30,6 +34,9 @@ const createEmhttpState = ({ fsState = 'Stopped', mdState, sslEnabled = true, + defaultUrl = 'https://Tower.local:4443', + lanMdns = 'Tower.local', + lanName = 'tower.local', }: { name?: string; comment?: string; @@ -37,6 +44,9 @@ const createEmhttpState = ({ fsState?: string; mdState?: SliceState['var']['mdState']; sslEnabled?: boolean; + defaultUrl?: string; + lanMdns?: string; + lanName?: string; } = {}): SliceState => ({ status: FileLoadStatus.LOADED, var: { @@ -52,8 +62,10 @@ const createEmhttpState = ({ networks: [{ ipaddr: ['192.168.1.10'] }] as unknown as SliceState['networks'], nginx: { sslEnabled, - lanName: 'tower.local', + defaultUrl, lanIp: '192.168.1.10', + lanMdns, + lanName, } as unknown as SliceState['nginx'], shares: [], disks: [], @@ -64,12 +76,16 @@ const createEmhttpState = ({ describe('ServerService', () => { let service: ServerService; + let avahiService: { restart: ReturnType }; let tempDirectory: string; let identConfigPath: string; beforeEach(async () => { vi.clearAllMocks(); - service = new ServerService(); + avahiService = { + restart: vi.fn().mockResolvedValue(undefined), + }; + service = new ServerService(avahiService as unknown as AvahiService); tempDirectory = await mkdtemp(join(tmpdir(), 'server-service-')); identConfigPath = join(tempDirectory, 'boot/config/ident.cfg'); @@ -77,6 +93,9 @@ describe('ServerService', () => { vi.mocked(getters.paths).mockReturnValue({ identConfig: identConfigPath, } as ReturnType); + vi.mocked(store.dispatch).mockReturnValue({ + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType); await mkdir(join(tempDirectory, 'boot/config'), { recursive: true }); await writeFile( @@ -172,6 +191,24 @@ describe('ServerService', () => { mdState: ArrayState.STOPPED, }) ); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'NewTower', + comment: 'desc', + sysModel: '', + fsState: 'Started', + mdState: ArrayState.STOPPED, + defaultUrl: 'https://NewTower.local:4443', + lanMdns: 'NewTower.local', + lanName: 'NewTower', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); await expect(service.updateServerIdentity('NewTower', 'desc')).resolves.toMatchObject({ name: 'NewTower', @@ -187,6 +224,22 @@ describe('ServerService', () => { ); return { ok: true } as Awaited>; }); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'Test1e', + comment: 'Test server1e', + sysModel: 'Model X200', + defaultUrl: 'https://Test1e.local:4443', + lanMdns: 'Test1e.local', + lanName: 'Test1e', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); const result = await service.updateServerIdentity('Test1e', 'Test server1e', 'Model X200'); @@ -224,6 +277,7 @@ describe('ServerService', () => { lanip: '192.168.1.10', localurl: 'http://192.168.1.10:80', remoteurl: '', + defaultUrl: 'https://Test1e.local:4443', }); }); @@ -236,6 +290,22 @@ describe('ServerService', () => { ); return { ok: true } as Awaited>; }); + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'TowerRenamed', + comment: 'Tower comment', + sysModel: 'Model X100', + defaultUrl: 'https://TowerRenamed.local:4443', + lanMdns: 'TowerRenamed.local', + lanName: 'TowerRenamed', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); await service.updateServerIdentity('TowerRenamed'); @@ -301,6 +371,87 @@ describe('ServerService', () => { name: 'Tower', comment: 'Primary host', }); + expect(avahiService.restart).not.toHaveBeenCalled(); + }); + + it('restarts Avahi, refreshes nginx state, and returns live defaultUrl after a name change', async () => { + vi.mocked(store.dispatch).mockImplementation(() => { + vi.mocked(getters.emhttp).mockReturnValue( + createEmhttpState({ + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + defaultUrl: 'https://Test1e.local:4443', + lanMdns: 'Test1e.local', + lanName: 'Test1e', + }) + ); + + return { + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType; + }); + + const result = await service.updateServerIdentity('Test1e', 'Primary host'); + + expect(avahiService.restart).toHaveBeenCalledTimes(1); + expect(store.dispatch).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ + name: 'Test1e', + comment: 'Primary host', + defaultUrl: 'https://Test1e.local:4443', + }); + }); + + it('skips Avahi restart and nginx refresh when only the comment changes', async () => { + const result = await service.updateServerIdentity('Tower', 'Primary host'); + + expect(avahiService.restart).not.toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + name: 'Tower', + comment: 'Primary host', + defaultUrl: 'https://Tower.local:4443', + }); + }); + + it('fails when Avahi restart fails after ident.cfg has been updated', async () => { + avahiService.restart.mockRejectedValue(new Error('avahi restart failed')); + + await expect(service.updateServerIdentity('Test1e', 'Primary host')).rejects.toMatchObject({ + message: 'Failed to update server identity', + extensions: { + cause: 'avahi restart failed', + persistedIdentity: { + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + }, + }, + }); + }); + + it('fails when live nginx state stays stale after Avahi restart', async () => { + vi.mocked(store.dispatch).mockReturnValue({ + unwrap: vi.fn().mockResolvedValue({ nginx: {} }), + } as unknown as ReturnType); + + await expect(service.updateServerIdentity('Test1e', 'Primary host')).rejects.toMatchObject({ + message: 'Failed to update server identity', + extensions: { + cause: 'Live network identity did not converge after Avahi restart', + persistedIdentity: { + name: 'Test1e', + comment: 'Primary host', + sysModel: 'Model X100', + }, + liveIdentity: { + lanName: 'tower.local', + lanMdns: 'Tower.local', + defaultUrl: 'https://Tower.local:4443', + }, + }, + }); }); it('throws generic failure when emcmd fails and ident.cfg stays unchanged', async () => { diff --git a/api/src/unraid-api/graph/resolvers/servers/server.service.ts b/api/src/unraid-api/graph/resolvers/servers/server.service.ts index 85ab7297ec..6896a3ae7e 100644 --- a/api/src/unraid-api/graph/resolvers/servers/server.service.ts +++ b/api/src/unraid-api/graph/resolvers/servers/server.service.ts @@ -5,18 +5,20 @@ import { GraphQLError } from 'graphql'; import * as ini from 'ini'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; -import { getters } from '@app/store/index.js'; +import { getters, store } from '@app/store/index.js'; +import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; +import { StateFileKey } from '@app/store/types.js'; +import { AvahiService } from '@app/unraid-api/avahi/avahi.service.js'; import { ArrayState } from '@app/unraid-api/graph/resolvers/array/array.model.js'; -import { - ProfileModel, - Server, - ServerStatus, -} from '@app/unraid-api/graph/resolvers/servers/server.model.js'; +import { buildServerResponse } from '@app/unraid-api/graph/resolvers/servers/build-server-response.js'; +import { Server } from '@app/unraid-api/graph/resolvers/servers/server.model.js'; @Injectable() export class ServerService { private readonly logger = new Logger(ServerService.name); + constructor(private readonly avahiService: AvahiService) {} + private async readPersistedIdentity(): Promise<{ name: string; comment: string; @@ -59,36 +61,64 @@ export class ServerService { }; } - private buildServerResponse( - emhttpState: ReturnType, - name: string, - comment: string - ): Server { - const guid = emhttpState.var?.regGuid ?? ''; - const lanip = emhttpState.networks?.[0]?.ipaddr?.[0] ?? ''; - const port = emhttpState.var?.port ?? ''; - const owner: ProfileModel = { - id: 'local', - username: 'root', - url: '', - avatar: '', - }; - + private getLiveIdentityState(emhttpState: ReturnType) { return { - id: 'local', - owner, - guid, - apikey: '', - name, - comment, - status: ServerStatus.ONLINE, - wanip: '', - lanip, - localurl: lanip ? `http://${lanip}${port ? `:${port}` : ''}` : '', - remoteurl: '', + lanName: emhttpState.nginx?.lanName ?? '', + lanMdns: emhttpState.nginx?.lanMdns ?? '', + defaultUrl: emhttpState.nginx?.defaultUrl?.trim() ?? '', }; } + private async refreshNginxStateAfterNameChange( + name: string, + persistedIdentity: Awaited> + ): Promise> { + try { + await this.avahiService.restart(); + } catch (error) { + this.logger.error('Failed to restart Avahi after server rename', error as Error); + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: + error instanceof Error && error.message + ? error.message + : 'Avahi restart failed after ident.cfg update', + persistedIdentity, + }, + }); + } + + try { + await store.dispatch(loadSingleStateFile(StateFileKey.nginx)).unwrap(); + } catch (error) { + this.logger.error('Failed to reload nginx state after server rename', error as Error); + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: + error instanceof Error && error.message + ? error.message + : 'Failed to reload nginx.ini after Avahi restart', + persistedIdentity, + }, + }); + } + + const refreshedEmhttp = getters.emhttp(); + const liveIdentity = this.getLiveIdentityState(refreshedEmhttp); + + if (liveIdentity.lanName !== name || !liveIdentity.defaultUrl) { + throw new GraphQLError('Failed to update server identity', { + extensions: { + cause: 'Live network identity did not converge after Avahi restart', + persistedIdentity, + liveIdentity, + }, + }); + } + + return refreshedEmhttp; + } + /** * Updates the server identity (name and comment/description). * The array must be stopped to change the server name. @@ -141,7 +171,10 @@ export class ServerService { if (name === currentName && nextComment === currentComment && nextSysModel === currentSysModel) { this.logger.log('Server identity unchanged; skipping emcmd update.'); - return this.buildServerResponse(currentEmhttp, currentName, currentComment); + return buildServerResponse(currentEmhttp, { + comment: currentComment, + name: currentName, + }); } if (name !== currentName) { @@ -189,8 +222,15 @@ export class ServerService { ); } - const latestEmhttp = getters.emhttp(); - return this.buildServerResponse(latestEmhttp, name, nextComment); + const latestEmhttp = + name !== currentName + ? await this.refreshNginxStateAfterNameChange(name, persistedIdentity) + : getters.emhttp(); + + return buildServerResponse(latestEmhttp, { + comment: nextComment, + name, + }); } catch (error) { if (error instanceof GraphQLError) { throw error; diff --git a/web/__test__/components/Onboarding/OnboardingModal.test.ts b/web/__test__/components/Onboarding/OnboardingModal.test.ts index 4b7423ec75..d124b30ce1 100644 --- a/web/__test__/components/Onboarding/OnboardingModal.test.ts +++ b/web/__test__/components/Onboarding/OnboardingModal.test.ts @@ -302,6 +302,7 @@ const createDeferred = () => { describe('OnboardingModal.vue', () => { beforeEach(() => { vi.clearAllMocks(); + window.history.replaceState({}, '', 'http://localhost:3000/'); onboardingModalStoreState.isVisible.value = true; onboardingModalStoreState.sessionSource.value = 'automatic'; onboardingStatusStore.canDisplayOnboardingModal.value = true; diff --git a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts index 8a2793d298..d7ede1d2d3 100644 --- a/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts +++ b/web/__test__/components/Onboarding/OnboardingSummaryStep.test.ts @@ -10,6 +10,7 @@ import { } from '@/components/Onboarding/graphql/coreSettings.mutations'; import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; +import { UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION } from '@/components/Onboarding/graphql/updateServerIdentityAndResume.mutation'; import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/updateSystemTime.mutation'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -46,6 +47,7 @@ const { setModalHiddenMock, updateSystemTimeMock, updateServerIdentityMock, + updateServerIdentityAndResumeMock, setThemeMock, setLocaleMock, updateSshSettingsMock, @@ -115,6 +117,7 @@ const { setModalHiddenMock: vi.fn(), updateSystemTimeMock: vi.fn().mockResolvedValue({}), updateServerIdentityMock: vi.fn().mockResolvedValue({}), + updateServerIdentityAndResumeMock: vi.fn().mockResolvedValue({}), setThemeMock: vi.fn().mockResolvedValue({}), setLocaleMock: vi.fn().mockResolvedValue({}), updateSshSettingsMock: vi.fn().mockResolvedValue({}), @@ -133,6 +136,16 @@ const createBootDevice = (id: string, sizeBytes: number, deviceName: string) => deviceName, }); +const mockLocation = { + origin: 'https://tower.local:4443', + hostname: 'tower.local', + pathname: '/', + search: '', + hash: '', + reload: vi.fn(), + replace: vi.fn(), +}; + vi.mock('pinia', async (importOriginal) => { const actual = await importOriginal(); return { @@ -141,6 +154,8 @@ vi.mock('pinia', async (importOriginal) => { }; }); +vi.stubGlobal('location', mockLocation); + vi.mock('@unraid/ui', () => ({ BrandButton: { props: ['text', 'disabled'], @@ -218,6 +233,9 @@ const setupApolloMocks = () => { if (doc === UPDATE_SERVER_IDENTITY_MUTATION) { return { mutate: updateServerIdentityMock }; } + if (doc === UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION) { + return { mutate: updateServerIdentityAndResumeMock }; + } if (doc === SET_THEME_MUTATION) { return { mutate: setThemeMock }; } @@ -266,7 +284,9 @@ const setupApolloMocks = () => { }; const mountComponent = (props: Record = {}) => { - const onComplete = vi.fn(); + const onComplete = + (props.onComplete as (() => void | Promise) | undefined) ?? + vi.fn<() => void | Promise>(); const wrapper = mount(OnboardingSummaryStep, { props: { draft: { @@ -332,6 +352,7 @@ const mountComponent = (props: Record = {}) => { vm.showBootDriveWarningDialog ? 'Confirm Drive Wipe' : '', vm.showApplyResultDialog ? vm.applyResultTitle : '', vm.showApplyResultDialog ? vm.applyResultMessage : '', + vm.showApplyResultDialog ? (vm.applyResultFollowUpMessage ?? '') : '', ] .filter(Boolean) .join(' '); @@ -342,11 +363,41 @@ const mountComponent = (props: Record = {}) => { return { wrapper, onComplete }; }; +const buildExpectedResumeInput = (expectedServerName: string) => ({ + draft: { + coreSettings: { + serverName: draftStore.serverName, + serverDescription: draftStore.serverDescription, + timeZone: draftStore.selectedTimeZone, + theme: draftStore.selectedTheme, + language: draftStore.selectedLanguage, + useSsh: draftStore.useSsh, + }, + plugins: { + selectedIds: Array.from(draftStore.selectedPlugins), + }, + internalBoot: { + bootMode: draftStore.bootMode, + skipped: draftStore.internalBootSkipped, + selection: draftStore.internalBootSelection, + }, + }, + navigation: { + currentStepId: 'NEXT_STEPS', + }, + internalBootState: { + applyAttempted: draftStore.internalBootApplyAttempted, + applySucceeded: draftStore.internalBootApplySucceeded, + }, + expectedServerName, +}); + interface SummaryVm { showApplyResultDialog: boolean; showBootDriveWarningDialog: boolean; applyResultTitle: string; applyResultMessage: string; + applyResultFollowUpMessage: string | null; applyResultSeverity: 'success' | 'warning' | 'error'; handleBootDriveWarningConfirm: () => Promise; handleBootDriveWarningCancel: () => void; @@ -447,6 +498,14 @@ describe('OnboardingSummaryStep', () => { vi.clearAllMocks(); document.body.innerHTML = ''; setupApolloMocks(); + mockLocation.origin = 'https://tower.local:4443'; + mockLocation.hostname = 'tower.local'; + mockLocation.pathname = '/'; + mockLocation.search = ''; + mockLocation.hash = ''; + mockLocation.reload.mockReset(); + mockLocation.replace.mockReset(); + updateServerIdentityAndResumeMock.mockReset(); draftStore.serverName = 'Tower'; draftStore.serverDescription = ''; @@ -515,7 +574,29 @@ describe('OnboardingSummaryStep', () => { }; updateSystemTimeMock.mockResolvedValue({}); - updateServerIdentityMock.mockResolvedValue({}); + updateServerIdentityMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Tower', + comment: '', + defaultUrl: 'https://Tower.local:4443', + }, + }, + }); + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Tower', + comment: '', + defaultUrl: 'https://Tower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); setThemeMock.mockResolvedValue({}); setLocaleMock.mockResolvedValue({}); updateSshSettingsMock.mockResolvedValue({}); @@ -859,6 +940,7 @@ describe('OnboardingSummaryStep', () => { expect(updateSystemTimeMock).not.toHaveBeenCalled(); expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); expect(setThemeMock).not.toHaveBeenCalled(); expect(setLocaleMock).not.toHaveBeenCalled(); expect(updateSshSettingsMock).not.toHaveBeenCalled(); @@ -880,6 +962,7 @@ describe('OnboardingSummaryStep', () => { await clickApply(wrapper); expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); expect(updateSystemTimeMock).not.toHaveBeenCalled(); expect(setThemeMock).not.toHaveBeenCalled(); expect(setLocaleMock).not.toHaveBeenCalled(); @@ -893,7 +976,12 @@ describe('OnboardingSummaryStep', () => { draftStore.serverName = 'Tower2'; }, assertExpected: () => { - expect(updateServerIdentityMock).toHaveBeenCalledWith({ name: 'Tower2', comment: '' }); + expect(updateServerIdentityAndResumeMock).toHaveBeenCalledWith({ + name: 'Tower2', + comment: '', + sysModel: undefined, + input: buildExpectedResumeInput('Tower2'), + }); }, }, { @@ -964,6 +1052,7 @@ describe('OnboardingSummaryStep', () => { scenario.caseName !== 'server identity description only' ) { expect(updateServerIdentityMock).not.toHaveBeenCalled(); + expect(updateServerIdentityAndResumeMock).not.toHaveBeenCalled(); } if (scenario.caseName !== 'timezone only') { expect(updateSystemTimeMock).not.toHaveBeenCalled(); @@ -1027,26 +1116,118 @@ describe('OnboardingSummaryStep', () => { expect(onComplete).not.toHaveBeenCalled(); }); - it('advances to next steps before reloading after a successful server rename', async () => { + it('advances to next steps before redirecting to the returned defaultUrl after a successful server rename', async () => { draftStore.serverName = 'Newtower'; - const reloadSpy = vi.spyOn(window.location, 'reload').mockImplementation(() => undefined); + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Newtower', + comment: '', + defaultUrl: 'https://Newtower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); + mockLocation.hostname = 'tower.local'; + mockLocation.pathname = '/Dashboard'; + mockLocation.search = '?foo=bar'; + mockLocation.hash = '#section'; const { wrapper, onComplete } = mountComponent(); await clickApply(wrapper); - expect(updateServerIdentityMock).toHaveBeenCalledWith({ + expect(updateServerIdentityAndResumeMock).toHaveBeenCalledWith({ name: 'Newtower', comment: '', sysModel: undefined, + input: buildExpectedResumeInput('Newtower'), }); expect(onComplete).not.toHaveBeenCalled(); + expect(getSummaryVm(wrapper).applyResultFollowUpMessage).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); + expect(wrapper.text()).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); + + await clickButtonByText(wrapper, 'OK'); + + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith( + 'https://newtower.local:4443/Dashboard?foo=bar#section' + ); + expect(mockLocation.reload).not.toHaveBeenCalled(); + }); + it('does not redirect after a non-rename server identity update succeeds', async () => { + draftStore.serverDescription = 'Primary host'; + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); await clickButtonByText(wrapper, 'OK'); expect(onComplete).toHaveBeenCalledTimes(1); - expect(reloadSpy).toHaveBeenCalledTimes(1); + expect(mockLocation.replace).not.toHaveBeenCalled(); + expect(mockLocation.reload).not.toHaveBeenCalled(); + }); + + it('shows a loading state while waiting to reconnect after a successful server rename', async () => { + draftStore.serverName = 'Newtower'; + updateServerIdentityAndResumeMock.mockResolvedValue({ + data: { + updateServerIdentity: { + id: 'local', + name: 'Newtower', + comment: '', + defaultUrl: 'https://Newtower.local:4443', + }, + onboarding: { + saveOnboardingDraft: true, + }, + }, + }); + + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); + + const confirmPromise = getSummaryVm(wrapper).handleApplyResultConfirm(); + await flushPromises(); + + expect(wrapper.find('[data-testid="onboarding-loading-state"]').exists()).toBe(true); + expect(wrapper.text()).toContain('Refreshing your connection'); + expect(wrapper.text()).toContain( + 'Your server name has been updated. The page may reload or prompt you to sign in again.' + ); + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith('https://newtower.local:4443/'); + expect(mockLocation.reload).not.toHaveBeenCalled(); + + await confirmPromise; + await flushPromises(); - reloadSpy.mockRestore(); + expect(onComplete).not.toHaveBeenCalled(); + }); + + it('reloads the current page instead of redirecting when the user is on an IP-based URL', async () => { + draftStore.serverName = 'Newtower'; + mockLocation.origin = 'http://192.168.1.2'; + mockLocation.hostname = '192.168.1.2'; + mockLocation.pathname = '/Dashboard'; + mockLocation.search = '?foo=bar'; + mockLocation.hash = '#section'; + + const { wrapper, onComplete } = mountComponent(); + + await clickApply(wrapper); + await clickButtonByText(wrapper, 'OK'); + + expect(onComplete).not.toHaveBeenCalled(); + expect(mockLocation.reload).not.toHaveBeenCalled(); + expect(mockLocation.replace).toHaveBeenCalledWith('http://192.168.1.2/Dashboard?foo=bar#section'); }); it('retries final identity update after transient network errors when SSH changed', async () => { @@ -1090,7 +1271,9 @@ describe('OnboardingSummaryStep', () => { it('prefers timeout result over warning classification when completion succeeds', async () => { draftStore.selectedPlugins = new Set(['community-apps']); draftStore.serverName = 'bad name!'; - updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + updateServerIdentityAndResumeMock.mockRejectedValue( + new Error('Server name contains invalid characters') + ); const timeoutError = new Error( 'Timed out waiting for install operation plugin-op to finish' ) as Error & { @@ -1108,7 +1291,9 @@ describe('OnboardingSummaryStep', () => { it('continues and classifies warnings when server identity mutation rejects invalid input', async () => { draftStore.serverName = 'bad name!'; - updateServerIdentityMock.mockRejectedValue(new Error('Server name contains invalid characters')); + updateServerIdentityAndResumeMock.mockRejectedValue( + new Error('Server name contains invalid characters') + ); const { wrapper } = mountComponent(); await clickApply(wrapper); diff --git a/web/src/components/Onboarding/graphql/coreSettings.mutations.ts b/web/src/components/Onboarding/graphql/coreSettings.mutations.ts index 9787bef7df..9976c493b9 100644 --- a/web/src/components/Onboarding/graphql/coreSettings.mutations.ts +++ b/web/src/components/Onboarding/graphql/coreSettings.mutations.ts @@ -8,6 +8,7 @@ export const UPDATE_SERVER_IDENTITY_MUTATION = gql` id name comment + defaultUrl } } `; diff --git a/web/src/components/Onboarding/graphql/updateServerIdentityAndResume.mutation.ts b/web/src/components/Onboarding/graphql/updateServerIdentityAndResume.mutation.ts new file mode 100644 index 0000000000..281cd72e9f --- /dev/null +++ b/web/src/components/Onboarding/graphql/updateServerIdentityAndResume.mutation.ts @@ -0,0 +1,20 @@ +import { graphql } from '~/composables/gql/gql.js'; + +export const UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION = graphql(/* GraphQL */ ` + mutation UpdateServerIdentityAndResume( + $name: String! + $comment: String + $sysModel: String + $input: SaveOnboardingDraftInput! + ) { + updateServerIdentity(name: $name, comment: $comment, sysModel: $sysModel) { + id + name + comment + defaultUrl + } + onboarding { + saveOnboardingDraft(input: $input) + } + } +`); diff --git a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue index 2e6ed2be85..dde07b130c 100644 --- a/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue +++ b/web/src/components/Onboarding/steps/OnboardingSummaryStep.vue @@ -45,6 +45,7 @@ import { } from '@/components/Onboarding/graphql/coreSettings.mutations'; import { GET_CORE_SETTINGS_QUERY } from '@/components/Onboarding/graphql/getCoreSettings.query'; import { INSTALLED_UNRAID_PLUGINS_QUERY } from '@/components/Onboarding/graphql/installedPlugins.query'; +import { UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION } from '@/components/Onboarding/graphql/updateServerIdentityAndResume.mutation'; import { UPDATE_SYSTEM_TIME_MUTATION } from '@/components/Onboarding/graphql/updateSystemTime.mutation'; import { convert } from 'convert'; @@ -57,7 +58,7 @@ import type { } from '@/components/Onboarding/onboardingWizardState'; import { useActivationCodeDataStore } from '~/components/Onboarding/store/activationCodeData'; -import { PluginInstallStatus, ThemeName } from '~/composables/gql/graphql'; +import { OnboardingWizardStepId, PluginInstallStatus, ThemeName } from '~/composables/gql/graphql'; export interface Props { draft: OnboardingWizardDraft; @@ -88,6 +89,9 @@ const setInternalBootState = (state: Partial) // Setup Mutations const { mutate: updateSystemTime } = useMutation(UPDATE_SYSTEM_TIME_MUTATION); const { mutate: updateServerIdentity } = useMutation(UPDATE_SERVER_IDENTITY_MUTATION); +const { mutate: updateServerIdentityAndResume } = useMutation( + UPDATE_SERVER_IDENTITY_AND_RESUME_MUTATION +); const { mutate: setTheme } = useMutation(SET_THEME_MUTATION); const { mutate: setLocale } = useMutation(SET_LOCALE_MUTATION); const { mutate: updateSshSettings } = useMutation(UPDATE_SSH_SETTINGS_MUTATION); @@ -210,7 +214,10 @@ const showBootDriveWarningDialog = ref(false); const applyResultTitle = ref(''); const applyResultMessage = ref(''); const applyResultSeverity = ref<'success' | 'warning' | 'error'>('success'); +const applyResultFollowUpMessage = ref(null); const shouldReloadAfterApplyResult = ref(false); +const redirectUrlAfterApplyResult = ref(null); +const isTransitioningAfterApplyResult = ref(false); const summaryT = (key: string, values?: Record) => t(`onboarding.summaryStep.${key}`, values ?? {}); const localApplyError = computed(() => error.value ?? null); @@ -329,6 +336,46 @@ const runWithTransientNetworkRetry = async ( throw lastError; }; +const normalizeLocationHostname = (hostname: string): string => { + if (hostname.startsWith('[') && hostname.endsWith(']')) { + return hostname.slice(1, -1); + } + + return hostname; +}; + +const isIpv4Literal = (hostname: string): boolean => { + const parts = hostname.split('.'); + if (parts.length !== 4) { + return false; + } + + return parts.every((part) => /^\d+$/.test(part) && Number(part) >= 0 && Number(part) <= 255); +}; + +const isIpv6Literal = (hostname: string): boolean => + hostname.includes(':') && /^[\da-f:.]+$/i.test(hostname); + +const shouldRedirectAfterRename = (hostname: string): boolean => { + const normalizedHostname = normalizeLocationHostname(hostname); + return !isIpv4Literal(normalizedHostname) && !isIpv6Literal(normalizedHostname); +}; + +const buildRedirectUrl = (baseUrl: string): string => { + const currentPath = `${location.pathname}${location.search}${location.hash}`; + const targetUrl = new URL(currentPath, baseUrl); + return targetUrl.toString(); +}; + +const buildResumeDraftInput = (expectedServerName: string) => ({ + draft: props.draft, + navigation: { + currentStepId: OnboardingWizardStepId.NEXT_STEPS, + }, + internalBootState: props.internalBootState, + expectedServerName, +}); + const isSshStateVerified = ( vars: { useSsh?: boolean | null; portssh?: number | string | null } | undefined, targetEnabled: boolean, @@ -587,7 +634,10 @@ const handleComplete = async () => { isProcessing.value = true; error.value = null; logs.value = []; // Clear logs + applyResultFollowUpMessage.value = null; + isTransitioningAfterApplyResult.value = false; shouldReloadAfterApplyResult.value = false; + redirectUrlAfterApplyResult.value = null; addLog(summaryT('logs.startingConfiguration'), 'info'); setInternalBootState({ @@ -643,16 +693,44 @@ const handleComplete = async () => { addLog(summaryT('logs.updatingServerIdentity', { name: targetCoreSettings.serverName }), 'info'); try { - await runWithTransientNetworkRetry( - () => - updateServerIdentity({ + const result = await runWithTransientNetworkRetry(() => { + if (serverNameChanged) { + // Write the resume step on the same request as the rename so the + // server-owned tracker survives cert prompts and re-login. + return updateServerIdentityAndResume({ name: targetCoreSettings.serverName, comment: targetCoreSettings.serverDescription, sysModel: shouldApplyPartnerSysModel ? activationSystemModel.value : undefined, - }), - shouldRetryNetworkMutations - ); + input: buildResumeDraftInput(targetCoreSettings.serverName), + }); + } + + return updateServerIdentity({ + name: targetCoreSettings.serverName, + comment: targetCoreSettings.serverDescription, + sysModel: shouldApplyPartnerSysModel ? activationSystemModel.value : undefined, + }); + }, shouldRetryNetworkMutations); if (serverNameChanged) { + const renameResult = result?.data?.updateServerIdentity; + const saveOnboardingDraftResult = + result?.data && 'onboarding' in result.data + ? result.data.onboarding?.saveOnboardingDraft + : undefined; + + if (saveOnboardingDraftResult === false) { + hadWarnings = true; + addLog(summaryT('logs.serverIdentityResumePending'), 'info'); + } + applyResultFollowUpMessage.value = summaryT('result.renameFollowUpMessage'); + const redirectBaseUrl = useReturnedDefaultUrlAfterRename + ? renameResult?.defaultUrl + : location.origin; + if (!redirectBaseUrl) { + throw new Error('Server rename succeeded but no redirect target was available'); + } + + redirectUrlAfterApplyResult.value = buildRedirectUrl(redirectBaseUrl); shouldReloadAfterApplyResult.value = true; } addLog(summaryT('logs.serverIdentityUpdated'), 'success'); @@ -1041,15 +1119,31 @@ const handleComplete = async () => { const handleApplyResultConfirm = async () => { showApplyResultDialog.value = false; - await Promise.resolve(props.onComplete()); - if (!shouldReloadAfterApplyResult.value) { + await Promise.resolve(props.onComplete()); return; } + isTransitioningAfterApplyResult.value = true; + const redirectUrl = redirectUrlAfterApplyResult.value; shouldReloadAfterApplyResult.value = false; - await nextTick(); - window.location.reload(); + redirectUrlAfterApplyResult.value = null; + let navigationTriggered = false; + try { + await nextTick(); + if (redirectUrl) { + navigationTriggered = true; + location.replace(redirectUrl); + return; + } + + navigationTriggered = true; + location.reload(); + } finally { + if (!navigationTriggered) { + isTransitioningAfterApplyResult.value = false; + } + } }; const handleApplyClick = async () => { @@ -1079,9 +1173,15 @@ const handleBack = () => { - -
+ + + + + + +