From 4575d3f560e56f98f8f8f40c7400337f0d04c645 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Oct 2025 13:57:38 -0400 Subject: [PATCH 01/33] feat(system-time): add SystemTime type and update resolvers for system time configuration - Introduced a new GraphQL type `SystemTime` to manage system time settings, including current time, timezone, NTP status, and NTP servers. - Added `systemTime` query to retrieve current system time configuration. - Implemented `updateSystemTime` mutation to modify system time settings. - Created corresponding service and resolver for handling system time logic. - Added input validation for updating system time, including manual date/time handling. - Integrated new module into the main resolver module for accessibility. This update enhances the API's capability to manage and retrieve system time configurations effectively. --- api/generated-schema.graphql | 39 ++++ .../graph/resolvers/resolvers.module.ts | 2 + .../system-time/system-time.model.ts | 58 ++++++ .../system-time/system-time.module.ts | 9 + .../system-time/system-time.resolver.ts | 33 ++++ .../system-time/system-time.service.spec.ts | 174 ++++++++++++++++++ .../system-time/system-time.service.ts | 165 +++++++++++++++++ 7 files changed, 480 insertions(+) create mode 100644 api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts create mode 100644 api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..9f326bcd3f 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1980,6 +1980,21 @@ type PublicOidcProvider { buttonStyle: String } +"""System time configuration and current status""" +type SystemTime { + """Current server time in ISO-8601 format (UTC)""" + currentTime: String! + + """IANA timezone identifier currently in use""" + timeZone: String! + + """Whether NTP/PTP time synchronization is enabled""" + useNtp: Boolean! + + """Configured NTP servers (empty strings indicate unused slots)""" + ntpServers: [String!]! +} + type UPSBattery { """ Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged @@ -2412,6 +2427,9 @@ type Query { """Validate an OIDC session token (internal use for CLI validation)""" validateOidcSession(token: String!): OidcSessionValidation! metrics: Metrics! + + """Retrieve current system time configuration""" + systemTime: SystemTime! upsDevices: [UPSDevice!]! upsDeviceById(id: String!): UPSDevice upsConfiguration: UPSConfiguration! @@ -2459,6 +2477,9 @@ type Mutation { """Initiates a flash drive backup using a configured remote.""" initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus! updateSettings(input: JSON!): UpdateSettingsResponse! + + """Update system time configuration""" + updateSystemTime(input: UpdateSystemTimeInput!): SystemTime! configureUps(config: UPSConfigInput!): Boolean! """ @@ -2501,6 +2522,24 @@ input InitiateFlashBackupInput { options: JSON } +input UpdateSystemTimeInput { + """New IANA timezone identifier to apply""" + timeZone: String + + """Enable or disable NTP-based synchronization""" + useNtp: Boolean + + """ + Ordered list of up to four NTP servers. Supply empty strings to clear positions. + """ + ntpServers: [String!] + + """ + Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss + """ + manualDateTime: String +} + input UPSConfigInput { """Enable or disable the UPS monitoring service""" service: UPSServiceState diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 751d42891e..72f9ed110a 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -24,6 +24,7 @@ import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registrati import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js'; import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js'; +import { SystemTimeModule } from '@app/unraid-api/graph/resolvers/system-time/system-time.module.js'; import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js'; import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js'; import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js'; @@ -51,6 +52,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; SettingsModule, SsoModule, MetricsModule, + SystemTimeModule, UPSModule, ], providers: [ diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts new file mode 100644 index 0000000000..93bf3e5791 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.model.ts @@ -0,0 +1,58 @@ +import { Field, InputType, ObjectType } from '@nestjs/graphql'; + +import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator'; + +const MANUAL_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/; + +@ObjectType({ description: 'System time configuration and current status' }) +export class SystemTime { + @Field({ description: 'Current server time in ISO-8601 format (UTC)' }) + currentTime!: string; + + @Field({ description: 'IANA timezone identifier currently in use' }) + timeZone!: string; + + @Field({ description: 'Whether NTP/PTP time synchronization is enabled' }) + useNtp!: boolean; + + @Field(() => [String], { + description: 'Configured NTP servers (empty strings indicate unused slots)', + }) + ntpServers!: string[]; +} + +@InputType() +export class UpdateSystemTimeInput { + @Field({ nullable: true, description: 'New IANA timezone identifier to apply' }) + @IsOptional() + @IsString() + timeZone?: string; + + @Field({ nullable: true, description: 'Enable or disable NTP-based synchronization' }) + @IsOptional() + @IsBoolean() + useNtp?: boolean; + + @Field(() => [String], { + nullable: true, + description: 'Ordered list of up to four NTP servers. Supply empty strings to clear positions.', + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(4) + @IsString({ each: true }) + ntpServers?: string[]; + + @Field({ + nullable: true, + description: 'Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss', + }) + @IsOptional() + @IsString() + @Matches(MANUAL_TIME_PATTERN, { + message: 'manualDateTime must be formatted as YYYY-MM-DD HH:mm:ss', + }) + manualDateTime?: string; +} + +export const MANUAL_TIME_REGEX = MANUAL_TIME_PATTERN; diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts new file mode 100644 index 0000000000..545b4da6ba --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { SystemTimeResolver } from '@app/unraid-api/graph/resolvers/system-time/system-time.resolver.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +@Module({ + providers: [SystemTimeResolver, SystemTimeService], +}) +export class SystemTimeModule {} diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts new file mode 100644 index 0000000000..9faae69600 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.resolver.ts @@ -0,0 +1,33 @@ +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { + SystemTime, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +@Resolver(() => SystemTime) +export class SystemTimeResolver { + constructor(private readonly systemTimeService: SystemTimeService) {} + + @Query(() => SystemTime, { description: 'Retrieve current system time configuration' }) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.VARS, + }) + async systemTime(): Promise { + return this.systemTimeService.getSystemTime(); + } + + @Mutation(() => SystemTime, { description: 'Update system time configuration' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + async updateSystemTime(@Args('input') input: UpdateSystemTimeInput): Promise { + return this.systemTimeService.updateSystemTime(input); + } +} diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts new file mode 100644 index 0000000000..6379933424 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.spec.ts @@ -0,0 +1,174 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import * as PhpLoaderModule from '@app/core/utils/plugins/php-loader.js'; +import { getters, store } from '@app/store/index.js'; +import { + MANUAL_TIME_REGEX, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; +import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +const phpLoaderSpy = vi.spyOn(PhpLoaderModule, 'phpLoader'); + +describe('SystemTimeService', () => { + let service: SystemTimeService; + const emhttpSpy = vi.spyOn(getters, 'emhttp'); + const pathsSpy = vi.spyOn(getters, 'paths'); + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + beforeEach(async () => { + vi.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [SystemTimeService], + }).compile(); + + service = module.get(SystemTimeService); + + emhttpSpy.mockReturnValue({ + var: { + timeZone: 'UTC', + useNtp: true, + ntpServer1: 'time1.google.com', + ntpServer2: 'time2.google.com', + ntpServer3: '', + ntpServer4: '', + }, + } as any); + + pathsSpy.mockReturnValue({ + webGuiBase: '/usr/local/emhttp/webGui', + } as any); + + dispatchSpy.mockResolvedValue({} as any); + vi.mocked(emcmd).mockResolvedValue({ ok: true } as any); + phpLoaderSpy.mockResolvedValue(''); + }); + + afterEach(() => { + emhttpSpy.mockReset(); + pathsSpy.mockReset(); + dispatchSpy.mockReset(); + phpLoaderSpy.mockReset(); + }); + + it('returns system time from store state', async () => { + const result = await service.getSystemTime(); + expect(result.timeZone).toBe('UTC'); + expect(result.useNtp).toBe(true); + expect(result.ntpServers).toEqual(['time1.google.com', 'time2.google.com', '', '']); + expect(typeof result.currentTime).toBe('string'); + }); + + it('updates time settings, disables NTP, and triggers timezone reset', async () => { + const oldState = { + var: { + timeZone: 'UTC', + useNtp: true, + ntpServer1: 'pool.ntp.org', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }, + } as any; + const newState = { + var: { + timeZone: 'America/Los_Angeles', + useNtp: false, + ntpServer1: 'time.google.com', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }, + } as any; + + emhttpSpy.mockImplementationOnce(() => oldState).mockReturnValue(newState); + + const input: UpdateSystemTimeInput = { + timeZone: 'America/Los_Angeles', + useNtp: false, + manualDateTime: '2025-01-22 10:00:00', + ntpServers: ['time.google.com'], + }; + + const result = await service.updateSystemTime(input); + + expect(emcmd).toHaveBeenCalledTimes(1); + const [commands, options] = vi.mocked(emcmd).mock.calls[0]; + expect(options).toEqual({ waitForToken: true }); + expect(commands).toEqual({ + setDateTime: 'apply', + timeZone: 'America/Los_Angeles', + USE_NTP: 'no', + NTP_SERVER1: 'time.google.com', + NTP_SERVER2: '', + NTP_SERVER3: '', + NTP_SERVER4: '', + newDateTime: '2025-01-22 10:00:00', + }); + + expect(phpLoaderSpy).toHaveBeenCalledWith({ + file: '/usr/local/emhttp/webGui/include/ResetTZ.php', + method: 'GET', + }); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect(typeof dispatchSpy.mock.calls[0][0]).toBe('function'); + + expect(result.timeZone).toBe('America/Los_Angeles'); + expect(result.useNtp).toBe(false); + expect(result.ntpServers).toEqual(['time.google.com', '', '', '']); + }); + + it('throws when provided timezone is invalid', async () => { + await expect(service.updateSystemTime({ timeZone: 'Not/AZone' })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(emcmd).not.toHaveBeenCalled(); + }); + + it('throws when disabling NTP without manualDateTime', async () => { + await expect(service.updateSystemTime({ useNtp: false })).rejects.toBeInstanceOf( + BadRequestException + ); + expect(emcmd).not.toHaveBeenCalled(); + }); + + it('retains manual mode and generates timestamp when not supplied', async () => { + const manualState = { + var: { + timeZone: 'UTC', + useNtp: false, + ntpServer1: '', + ntpServer2: '', + ntpServer3: '', + ntpServer4: '', + }, + } as any; + + const manualStateAfter = { + var: { + ...manualState.var, + ntpServer1: 'time.cloudflare.com', + }, + } as any; + + emhttpSpy.mockImplementationOnce(() => manualState).mockReturnValue(manualStateAfter); + + const result = await service.updateSystemTime({ ntpServers: ['time.cloudflare.com'] }); + + const [commands] = vi.mocked(emcmd).mock.calls[0]; + expect(commands.USE_NTP).toBe('no'); + expect(commands.NTP_SERVER1).toBe('time.cloudflare.com'); + expect(commands.newDateTime).toMatch(MANUAL_TIME_REGEX); + expect(phpLoaderSpy).not.toHaveBeenCalled(); + expect(result.ntpServers).toEqual(['time.cloudflare.com', '', '', '']); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts new file mode 100644 index 0000000000..5b84793cc3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/system-time/system-time.service.ts @@ -0,0 +1,165 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { join } from 'node:path'; + +import type { Var } from '@app/core/types/states/var.js'; +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { phpLoader } from '@app/core/utils/plugins/php-loader.js'; +import { getters, store } from '@app/store/index.js'; +import { loadStateFiles } from '@app/store/modules/emhttp.js'; +import { + SystemTime, + UpdateSystemTimeInput, +} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js'; + +const MAX_NTP_SERVERS = 4; + +@Injectable() +export class SystemTimeService { + private readonly logger = new Logger(SystemTimeService.name); + + public async getSystemTime(): Promise { + const varState = this.getVarState(); + const ntpServers = this.extractNtpServers(varState); + + return { + currentTime: new Date().toISOString(), + timeZone: varState.timeZone ?? 'UTC', + useNtp: Boolean(varState.useNtp), + ntpServers, + }; + } + + public async updateSystemTime(input: UpdateSystemTimeInput): Promise { + const current = this.getVarState(); + + const desiredTimeZone = (input.timeZone ?? current.timeZone)?.trim(); + if (!desiredTimeZone) { + throw new BadRequestException('A valid time zone is required.'); + } + this.validateTimeZone(desiredTimeZone); + + const desiredUseNtp = input.useNtp ?? Boolean(current.useNtp); + const desiredServers = this.normalizeNtpServers(input.ntpServers, current); + + const commands: Record = { + setDateTime: 'apply', + timeZone: desiredTimeZone, + USE_NTP: desiredUseNtp ? 'yes' : 'no', + }; + + desiredServers.forEach((server, index) => { + commands[`NTP_SERVER${index + 1}`] = server; + }); + + const switchingToManual = desiredUseNtp === false && Boolean(current.useNtp); + if (desiredUseNtp === false) { + let manualDateTime = input.manualDateTime?.trim(); + if (switchingToManual && !manualDateTime) { + throw new BadRequestException( + 'manualDateTime is required when disabling NTP synchronization.' + ); + } + if (!manualDateTime) { + manualDateTime = this.formatManualDateTime(new Date()); + } + commands.newDateTime = manualDateTime; + } + + const timezoneChanged = desiredTimeZone !== (current.timeZone ?? ''); + + this.logger.log( + `Updating system time settings (zone=${desiredTimeZone}, useNtp=${desiredUseNtp}, timezoneChanged=${timezoneChanged})` + ); + + try { + await emcmd(commands, { waitForToken: true }); + this.logger.log('emcmd executed successfully for system time update.'); + } catch (error) { + this.logger.error('Failed to update system time via emcmd', error as Error); + throw error; + } + + if (timezoneChanged) { + await this.resetTimezoneWatcher(); + } + + try { + await store.dispatch(loadStateFiles()); + } catch (error) { + this.logger.warn('Failed to reload emhttp state after updating system time', error as Error); + } + + return this.getSystemTime(); + } + + private getVarState(): Partial { + const state = getters.emhttp(); + return (state?.var ?? {}) as Partial; + } + + private extractNtpServers(varState: Partial): string[] { + const servers = [ + varState.ntpServer1 ?? '', + varState.ntpServer2 ?? '', + varState.ntpServer3 ?? '', + varState.ntpServer4 ?? '', + ].map((value) => value?.trim() ?? ''); + + while (servers.length < MAX_NTP_SERVERS) { + servers.push(''); + } + + return servers; + } + + private normalizeNtpServers(override: string[] | undefined, current: Partial): string[] { + if (!override) { + return this.extractNtpServers(current); + } + + const sanitized = override + .slice(0, MAX_NTP_SERVERS) + .map((server) => this.sanitizeNtpServer(server)); + + const result: string[] = []; + for (let i = 0; i < MAX_NTP_SERVERS; i += 1) { + result[i] = sanitized[i] ?? ''; + } + + return result; + } + + private sanitizeNtpServer(server?: string): string { + if (!server) { + return ''; + } + return server.trim().slice(0, 40); + } + + private validateTimeZone(timeZone: string) { + try { + new Intl.DateTimeFormat('en-US', { timeZone }); + } catch (error) { + this.logger.warn(`Invalid time zone provided: ${timeZone}`); + throw new BadRequestException(`Invalid time zone: ${timeZone}`); + } + } + + private formatManualDateTime(date: Date): string { + const pad = (value: number) => value.toString().padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + } + + private async resetTimezoneWatcher() { + const webGuiBase = getters.paths().webGuiBase ?? '/usr/local/emhttp/webGui'; + const scriptPath = join(webGuiBase, 'include', 'ResetTZ.php'); + + try { + await phpLoader({ file: scriptPath, method: 'GET' }); + this.logger.debug('Executed ResetTZ.php to refresh timezone watchers.'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to execute ResetTZ.php at ${scriptPath}: ${message}`); + } + } +} From 091c29dfe6618aac7ce106782421a8025e4ef6fc Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Oct 2025 18:10:40 -0400 Subject: [PATCH 02/33] feat(activation): enhance activation modal with timezone selection and system time mutation - Added a new `ActivationTimezoneStep` component for selecting time zones during the activation process. - Integrated timezone selection with the `updateSystemTime` mutation to update the system's timezone settings. - Updated the `ActivationModal` and `ActivationSteps` components to accommodate the new timezone step. - Improved visibility logic for the activation modal based on the presence of an activation code. - Introduced a new GraphQL mutation for updating system time, enhancing the API's capability to manage time settings. This update streamlines the activation process by allowing users to set their timezone, ensuring accurate timestamps across the system. --- api/src/core/utils/clients/emcmd.ts | 114 ++++++++++----- pnpm-lock.yaml | 16 ++- web/components.d.ts | 1 + web/package.json | 1 + .../components/Activation/ActivationModal.vue | 87 +++++++++--- .../components/Activation/ActivationSteps.vue | 46 ++++-- .../Activation/ActivationTimezoneStep.vue | 132 ++++++++++++++++++ .../Activation/store/activationCodeModal.ts | 15 +- .../Activation/updateSystemTime.mutation.ts | 12 ++ web/src/components/Wrapper/mount-engine.ts | 3 +- web/src/composables/gql/gql.ts | 6 + web/src/composables/gql/graphql.ts | 41 ++++++ 12 files changed, 397 insertions(+), 77 deletions(-) create mode 100644 web/src/components/Activation/ActivationTimezoneStep.vue create mode 100644 web/src/components/Activation/updateSystemTime.mutation.ts diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index dfd29a4697..2440651179 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -1,4 +1,7 @@ +import { readFile } from 'node:fs/promises'; + import { got } from 'got'; +import * as ini from 'ini'; import retry from 'p-retry'; import { AppError } from '@app/core/errors/app-error.js'; @@ -8,6 +11,60 @@ import { store } from '@app/store/index.js'; import { loadSingleStateFile } from '@app/store/modules/emhttp.js'; import { StateFileKey } from '@app/store/types.js'; +const VAR_INI_PATH = '/var/local/emhttp/var.ini'; + +const readCsrfTokenFromVarIni = async (): Promise => { + try { + const iniContents = await readFile(VAR_INI_PATH, 'utf-8'); + const parsed = ini.parse(iniContents) as { csrf_token?: string }; + return parsed?.csrf_token; + } catch (error) { + appLogger.debug(`Unable to read CSRF token from ${VAR_INI_PATH}: %o`, error); + return undefined; + } +}; + +const ensureCsrfToken = async ( + currentToken: string | undefined, + waitForToken: boolean +): Promise => { + if (currentToken) { + return currentToken; + } + + const tokenFromIni = await readCsrfTokenFromVarIni(); + if (tokenFromIni) { + return tokenFromIni; + } + + if (!waitForToken) { + return undefined; + } + + return retry( + async (retries) => { + if (retries > 1) { + appLogger.info('Waiting for CSRF token...'); + } + const loadedState = await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap(); + + const token = loadedState && 'var' in loadedState ? loadedState.var.csrfToken : undefined; + if (!token) { + throw new Error('CSRF token not found yet'); + } + return token; + }, + { + minTimeout: 5000, + maxTimeout: 10000, + retries: 10, + } + ).catch((error) => { + appLogger.error('Failed to load CSRF token after multiple retries', error); + throw new AppError('Failed to load CSRF token after multiple retries'); + }); +}; + /** * Run a command with emcmd. */ @@ -23,46 +80,37 @@ export const emcmd = async ( throw new AppError('No emhttpd socket path found'); } - let { csrfToken } = getters.emhttp().var; - - if (!csrfToken && waitForToken) { - csrfToken = await retry( - async (retries) => { - if (retries > 1) { - appLogger.info('Waiting for CSRF token...'); - } - const loadedState = await store.dispatch(loadSingleStateFile(StateFileKey.var)).unwrap(); - - let token: string | undefined; - if (loadedState && 'var' in loadedState) { - token = loadedState.var.csrfToken; - } - if (!token) { - throw new Error('CSRF token not found yet'); - } - return token; - }, - { - minTimeout: 5000, - maxTimeout: 10000, - retries: 10, - } - ).catch((error) => { - appLogger.error('Failed to load CSRF token after multiple retries', error); - throw new AppError('Failed to load CSRF token after multiple retries'); - }); - } + const stateToken = getters.emhttp().var?.csrfToken; + const csrfToken = await ensureCsrfToken(stateToken, waitForToken); appLogger.debug(`Executing emcmd with commands: ${JSON.stringify(commands)}`); try { - const paramsObj = { ...commands, csrf_token: csrfToken }; - const params = new URLSearchParams(paramsObj); - const response = await got.get(`http://unix:${socketPath}:/update.htm`, { + const params = new URLSearchParams(); + Object.entries({ ...commands }).forEach(([key, value]) => { + const stringValue = value == null ? '' : String(value); + params.append(key, stringValue); + }); + params.append('csrf_token', csrfToken ?? ''); + + const response = await got.post(`http://unix:${socketPath}:/update`, { enableUnixSockets: true, - searchParams: params, + body: params.toString(), + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + throwHttpErrors: false, }); + if (response.statusCode >= 400) { + throw new Error(`emcmd request failed with status ${response.statusCode}`); + } + + const trimmedBody = response.body?.trim(); + if (trimmedBody) { + throw new Error(trimmedBody); + } + appLogger.debug('emcmd executed successfully'); return response; } catch (error: any) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a4..831f22e6fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1100,6 +1100,9 @@ importers: '@vueuse/integrations': specifier: 13.8.0 version: 13.8.0(change-case@5.4.4)(focus-trap@7.6.5)(fuse.js@7.1.0)(jwt-decode@4.0.0)(vue@3.5.20(typescript@5.9.2)) + '@vvo/tzdb': + specifier: ^6.186.0 + version: 6.186.0 ajv: specifier: 8.17.1 version: 8.17.1 @@ -5634,6 +5637,9 @@ packages: peerDependencies: vue: ^3.5.0 + '@vvo/tzdb@6.186.0': + resolution: {integrity: sha512-UHSNLPElPVd70GmRhZxlD5oCnD+tq1KtVGRu7j0oMuSEeyz4StgZYj/guwCjg4Ew8uFCTI3yUO4TJlpDd5n7wg==} + '@whatwg-node/disposablestack@0.0.5': resolution: {integrity: sha512-9lXugdknoIequO4OYvIjhygvfSEgnO8oASLqLelnDhkRjgBZhc39shC3QSlZuyDO9bgYSIVa2cHAiN+St3ty4w==} engines: {node: '>=18.0.0'} @@ -12434,8 +12440,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.5: + resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -16500,7 +16506,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.5 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -17759,6 +17765,8 @@ snapshots: dependencies: vue: 3.5.20(typescript@5.9.2) + '@vvo/tzdb@6.186.0': {} + '@whatwg-node/disposablestack@0.0.5': dependencies: tslib: 2.8.1 @@ -25339,7 +25347,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.5: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/web/components.d.ts b/web/components.d.ts index 87e1693d43..1c87ec74de 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -12,6 +12,7 @@ declare module 'vue' { ActivationPartnerLogo: typeof import('./src/components/Activation/ActivationPartnerLogo.vue')['default'] ActivationPartnerLogoImg: typeof import('./src/components/Activation/ActivationPartnerLogoImg.vue')['default'] ActivationSteps: typeof import('./src/components/Activation/ActivationSteps.vue')['default'] + ActivationTimezoneStep: typeof import('./src/components/Activation/ActivationTimezoneStep.vue')['default'] 'ApiKeyAuthorize.standalone': typeof import('./src/components/ApiKeyAuthorize.standalone.vue')['default'] ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default'] ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default'] diff --git a/web/package.json b/web/package.json index e5ee057f1a..3a4a799fa1 100644 --- a/web/package.json +++ b/web/package.json @@ -114,6 +114,7 @@ "@vue/apollo-composable": "4.2.2", "@vueuse/components": "13.8.0", "@vueuse/integrations": "13.8.0", + "@vvo/tzdb": "^6.186.0", "ajv": "8.17.1", "ansi_up": "6.0.6", "class-variance-authority": "0.7.1", diff --git a/web/src/components/Activation/ActivationModal.vue b/web/src/components/Activation/ActivationModal.vue index 72c7979ea5..73be815679 100644 --- a/web/src/components/Activation/ActivationModal.vue +++ b/web/src/components/Activation/ActivationModal.vue @@ -1,6 +1,5 @@