Skip to content

Commit a2f079e

Browse files
committed
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.
1 parent 754966d commit a2f079e

7 files changed

Lines changed: 480 additions & 0 deletions

File tree

‎api/generated-schema.graphql‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1961,6 +1961,21 @@ type PublicOidcProvider {
19611961
buttonStyle: String
19621962
}
19631963

1964+
"""System time configuration and current status"""
1965+
type SystemTime {
1966+
"""Current server time in ISO-8601 format (UTC)"""
1967+
currentTime: String!
1968+
1969+
"""IANA timezone identifier currently in use"""
1970+
timeZone: String!
1971+
1972+
"""Whether NTP/PTP time synchronization is enabled"""
1973+
useNtp: Boolean!
1974+
1975+
"""Configured NTP servers (empty strings indicate unused slots)"""
1976+
ntpServers: [String!]!
1977+
}
1978+
19641979
type UPSBattery {
19651980
"""
19661981
Battery charge level as a percentage (0-100). Unit: percent (%). Example: 100 means battery is fully charged
@@ -2393,6 +2408,9 @@ type Query {
23932408
"""Validate an OIDC session token (internal use for CLI validation)"""
23942409
validateOidcSession(token: String!): OidcSessionValidation!
23952410
metrics: Metrics!
2411+
2412+
"""Retrieve current system time configuration"""
2413+
systemTime: SystemTime!
23962414
upsDevices: [UPSDevice!]!
23972415
upsDeviceById(id: String!): UPSDevice
23982416
upsConfiguration: UPSConfiguration!
@@ -2440,6 +2458,9 @@ type Mutation {
24402458
"""Initiates a flash drive backup using a configured remote."""
24412459
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
24422460
updateSettings(input: JSON!): UpdateSettingsResponse!
2461+
2462+
"""Update system time configuration"""
2463+
updateSystemTime(input: UpdateSystemTimeInput!): SystemTime!
24432464
configureUps(config: UPSConfigInput!): Boolean!
24442465

24452466
"""
@@ -2482,6 +2503,24 @@ input InitiateFlashBackupInput {
24822503
options: JSON
24832504
}
24842505

2506+
input UpdateSystemTimeInput {
2507+
"""New IANA timezone identifier to apply"""
2508+
timeZone: String
2509+
2510+
"""Enable or disable NTP-based synchronization"""
2511+
useNtp: Boolean
2512+
2513+
"""
2514+
Ordered list of up to four NTP servers. Supply empty strings to clear positions.
2515+
"""
2516+
ntpServers: [String!]
2517+
2518+
"""
2519+
Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss
2520+
"""
2521+
manualDateTime: String
2522+
}
2523+
24852524
input UPSConfigInput {
24862525
"""Enable or disable the UPS monitoring service"""
24872526
service: UPSServiceState

‎api/src/unraid-api/graph/resolvers/resolvers.module.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registrati
2424
import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js';
2525
import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js';
2626
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
27+
import { SystemTimeModule } from '@app/unraid-api/graph/resolvers/system-time/system-time.module.js';
2728
import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js';
2829
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
2930
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';
5152
SettingsModule,
5253
SsoModule,
5354
MetricsModule,
55+
SystemTimeModule,
5456
UPSModule,
5557
],
5658
providers: [
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { Field, InputType, ObjectType } from '@nestjs/graphql';
2+
3+
import { ArrayMaxSize, IsArray, IsBoolean, IsOptional, IsString, Matches } from 'class-validator';
4+
5+
const MANUAL_TIME_PATTERN = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
6+
7+
@ObjectType({ description: 'System time configuration and current status' })
8+
export class SystemTime {
9+
@Field({ description: 'Current server time in ISO-8601 format (UTC)' })
10+
currentTime!: string;
11+
12+
@Field({ description: 'IANA timezone identifier currently in use' })
13+
timeZone!: string;
14+
15+
@Field({ description: 'Whether NTP/PTP time synchronization is enabled' })
16+
useNtp!: boolean;
17+
18+
@Field(() => [String], {
19+
description: 'Configured NTP servers (empty strings indicate unused slots)',
20+
})
21+
ntpServers!: string[];
22+
}
23+
24+
@InputType()
25+
export class UpdateSystemTimeInput {
26+
@Field({ nullable: true, description: 'New IANA timezone identifier to apply' })
27+
@IsOptional()
28+
@IsString()
29+
timeZone?: string;
30+
31+
@Field({ nullable: true, description: 'Enable or disable NTP-based synchronization' })
32+
@IsOptional()
33+
@IsBoolean()
34+
useNtp?: boolean;
35+
36+
@Field(() => [String], {
37+
nullable: true,
38+
description: 'Ordered list of up to four NTP servers. Supply empty strings to clear positions.',
39+
})
40+
@IsOptional()
41+
@IsArray()
42+
@ArrayMaxSize(4)
43+
@IsString({ each: true })
44+
ntpServers?: string[];
45+
46+
@Field({
47+
nullable: true,
48+
description: 'Manual date/time to apply when disabling NTP, expected format YYYY-MM-DD HH:mm:ss',
49+
})
50+
@IsOptional()
51+
@IsString()
52+
@Matches(MANUAL_TIME_PATTERN, {
53+
message: 'manualDateTime must be formatted as YYYY-MM-DD HH:mm:ss',
54+
})
55+
manualDateTime?: string;
56+
}
57+
58+
export const MANUAL_TIME_REGEX = MANUAL_TIME_PATTERN;
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { SystemTimeResolver } from '@app/unraid-api/graph/resolvers/system-time/system-time.resolver.js';
4+
import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js';
5+
6+
@Module({
7+
providers: [SystemTimeResolver, SystemTimeService],
8+
})
9+
export class SystemTimeModule {}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
2+
3+
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
4+
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
5+
6+
import {
7+
SystemTime,
8+
UpdateSystemTimeInput,
9+
} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js';
10+
import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js';
11+
12+
@Resolver(() => SystemTime)
13+
export class SystemTimeResolver {
14+
constructor(private readonly systemTimeService: SystemTimeService) {}
15+
16+
@Query(() => SystemTime, { description: 'Retrieve current system time configuration' })
17+
@UsePermissions({
18+
action: AuthAction.READ_ANY,
19+
resource: Resource.VARS,
20+
})
21+
async systemTime(): Promise<SystemTime> {
22+
return this.systemTimeService.getSystemTime();
23+
}
24+
25+
@Mutation(() => SystemTime, { description: 'Update system time configuration' })
26+
@UsePermissions({
27+
action: AuthAction.UPDATE_ANY,
28+
resource: Resource.CONFIG,
29+
})
30+
async updateSystemTime(@Args('input') input: UpdateSystemTimeInput): Promise<SystemTime> {
31+
return this.systemTimeService.updateSystemTime(input);
32+
}
33+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { BadRequestException } from '@nestjs/common';
2+
import { Test, TestingModule } from '@nestjs/testing';
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
6+
import { emcmd } from '@app/core/utils/clients/emcmd.js';
7+
import * as PhpLoaderModule from '@app/core/utils/plugins/php-loader.js';
8+
import { getters, store } from '@app/store/index.js';
9+
import {
10+
MANUAL_TIME_REGEX,
11+
UpdateSystemTimeInput,
12+
} from '@app/unraid-api/graph/resolvers/system-time/system-time.model.js';
13+
import { SystemTimeService } from '@app/unraid-api/graph/resolvers/system-time/system-time.service.js';
14+
15+
vi.mock('@app/core/utils/clients/emcmd.js', () => ({
16+
emcmd: vi.fn(),
17+
}));
18+
19+
const phpLoaderSpy = vi.spyOn(PhpLoaderModule, 'phpLoader');
20+
21+
describe('SystemTimeService', () => {
22+
let service: SystemTimeService;
23+
const emhttpSpy = vi.spyOn(getters, 'emhttp');
24+
const pathsSpy = vi.spyOn(getters, 'paths');
25+
const dispatchSpy = vi.spyOn(store, 'dispatch');
26+
27+
beforeEach(async () => {
28+
vi.clearAllMocks();
29+
30+
const module: TestingModule = await Test.createTestingModule({
31+
providers: [SystemTimeService],
32+
}).compile();
33+
34+
service = module.get<SystemTimeService>(SystemTimeService);
35+
36+
emhttpSpy.mockReturnValue({
37+
var: {
38+
timeZone: 'UTC',
39+
useNtp: true,
40+
ntpServer1: 'time1.google.com',
41+
ntpServer2: 'time2.google.com',
42+
ntpServer3: '',
43+
ntpServer4: '',
44+
},
45+
} as any);
46+
47+
pathsSpy.mockReturnValue({
48+
webGuiBase: '/usr/local/emhttp/webGui',
49+
} as any);
50+
51+
dispatchSpy.mockResolvedValue({} as any);
52+
vi.mocked(emcmd).mockResolvedValue({ ok: true } as any);
53+
phpLoaderSpy.mockResolvedValue('');
54+
});
55+
56+
afterEach(() => {
57+
emhttpSpy.mockReset();
58+
pathsSpy.mockReset();
59+
dispatchSpy.mockReset();
60+
phpLoaderSpy.mockReset();
61+
});
62+
63+
it('returns system time from store state', async () => {
64+
const result = await service.getSystemTime();
65+
expect(result.timeZone).toBe('UTC');
66+
expect(result.useNtp).toBe(true);
67+
expect(result.ntpServers).toEqual(['time1.google.com', 'time2.google.com', '', '']);
68+
expect(typeof result.currentTime).toBe('string');
69+
});
70+
71+
it('updates time settings, disables NTP, and triggers timezone reset', async () => {
72+
const oldState = {
73+
var: {
74+
timeZone: 'UTC',
75+
useNtp: true,
76+
ntpServer1: 'pool.ntp.org',
77+
ntpServer2: '',
78+
ntpServer3: '',
79+
ntpServer4: '',
80+
},
81+
} as any;
82+
const newState = {
83+
var: {
84+
timeZone: 'America/Los_Angeles',
85+
useNtp: false,
86+
ntpServer1: 'time.google.com',
87+
ntpServer2: '',
88+
ntpServer3: '',
89+
ntpServer4: '',
90+
},
91+
} as any;
92+
93+
emhttpSpy.mockImplementationOnce(() => oldState).mockReturnValue(newState);
94+
95+
const input: UpdateSystemTimeInput = {
96+
timeZone: 'America/Los_Angeles',
97+
useNtp: false,
98+
manualDateTime: '2025-01-22 10:00:00',
99+
ntpServers: ['time.google.com'],
100+
};
101+
102+
const result = await service.updateSystemTime(input);
103+
104+
expect(emcmd).toHaveBeenCalledTimes(1);
105+
const [commands, options] = vi.mocked(emcmd).mock.calls[0];
106+
expect(options).toEqual({ waitForToken: true });
107+
expect(commands).toEqual({
108+
setDateTime: 'apply',
109+
timeZone: 'America/Los_Angeles',
110+
USE_NTP: 'no',
111+
NTP_SERVER1: 'time.google.com',
112+
NTP_SERVER2: '',
113+
NTP_SERVER3: '',
114+
NTP_SERVER4: '',
115+
newDateTime: '2025-01-22 10:00:00',
116+
});
117+
118+
expect(phpLoaderSpy).toHaveBeenCalledWith({
119+
file: '/usr/local/emhttp/webGui/include/ResetTZ.php',
120+
method: 'GET',
121+
});
122+
expect(dispatchSpy).toHaveBeenCalledTimes(1);
123+
expect(typeof dispatchSpy.mock.calls[0][0]).toBe('function');
124+
125+
expect(result.timeZone).toBe('America/Los_Angeles');
126+
expect(result.useNtp).toBe(false);
127+
expect(result.ntpServers).toEqual(['time.google.com', '', '', '']);
128+
});
129+
130+
it('throws when provided timezone is invalid', async () => {
131+
await expect(service.updateSystemTime({ timeZone: 'Not/AZone' })).rejects.toBeInstanceOf(
132+
BadRequestException
133+
);
134+
expect(emcmd).not.toHaveBeenCalled();
135+
});
136+
137+
it('throws when disabling NTP without manualDateTime', async () => {
138+
await expect(service.updateSystemTime({ useNtp: false })).rejects.toBeInstanceOf(
139+
BadRequestException
140+
);
141+
expect(emcmd).not.toHaveBeenCalled();
142+
});
143+
144+
it('retains manual mode and generates timestamp when not supplied', async () => {
145+
const manualState = {
146+
var: {
147+
timeZone: 'UTC',
148+
useNtp: false,
149+
ntpServer1: '',
150+
ntpServer2: '',
151+
ntpServer3: '',
152+
ntpServer4: '',
153+
},
154+
} as any;
155+
156+
const manualStateAfter = {
157+
var: {
158+
...manualState.var,
159+
ntpServer1: 'time.cloudflare.com',
160+
},
161+
} as any;
162+
163+
emhttpSpy.mockImplementationOnce(() => manualState).mockReturnValue(manualStateAfter);
164+
165+
const result = await service.updateSystemTime({ ntpServers: ['time.cloudflare.com'] });
166+
167+
const [commands] = vi.mocked(emcmd).mock.calls[0];
168+
expect(commands.USE_NTP).toBe('no');
169+
expect(commands.NTP_SERVER1).toBe('time.cloudflare.com');
170+
expect(commands.newDateTime).toMatch(MANUAL_TIME_REGEX);
171+
expect(phpLoaderSpy).not.toHaveBeenCalled();
172+
expect(result.ntpServers).toEqual(['time.cloudflare.com', '', '', '']);
173+
});
174+
});

0 commit comments

Comments
 (0)