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.
This commit is contained in:
Eli Bosley
2025-10-09 13:57:38 -04:00
parent 9ef1cf1eca
commit 932d2cf389
7 changed files with 480 additions and 0 deletions

View File

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

View File

@@ -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: [

View File

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

View File

@@ -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 {}

View File

@@ -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<SystemTime> {
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<SystemTime> {
return this.systemTimeService.updateSystemTime(input);
}
}

View File

@@ -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>(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', '', '', '']);
});
});

View File

@@ -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<SystemTime> {
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<SystemTime> {
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<string, string> = {
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<Var> {
const state = getters.emhttp();
return (state?.var ?? {}) as Partial<Var>;
}
private extractNtpServers(varState: Partial<Var>): 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<Var>): 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}`);
}
}
}