From 932d2cf389ba0adbee3741be20d40da3efaf91f5 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Thu, 9 Oct 2025 13:57:38 -0400 Subject: [PATCH] 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 126a982ad..39d4e8245 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -2201,6 +2201,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 @@ -2633,6 +2648,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! @@ -2696,6 +2714,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! """ @@ -2738,6 +2759,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 34a7884d6..080204e91 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'; @@ -52,6 +53,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 000000000..93bf3e579 --- /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 000000000..545b4da6b --- /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 000000000..9faae6960 --- /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 000000000..637993342 --- /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 000000000..5b84793cc --- /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}`); + } + } +}