mirror of
https://github.com/unraid/api.git
synced 2026-01-04 23:50:37 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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', '', '', '']);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user