diff --git a/api/dev/Unraid.net/myservers.cfg b/api/dev/Unraid.net/myservers.cfg index 0618a5257..1c664dad2 100644 --- a/api/dev/Unraid.net/myservers.cfg +++ b/api/dev/Unraid.net/myservers.cfg @@ -1,5 +1,5 @@ [api] -version="4.1.3" +version="4.4.1" extraOrigins="https://google.com,https://test.com" [local] sandbox="yes" diff --git a/api/dev/states/myservers.cfg b/api/dev/states/myservers.cfg index e5ba60d29..f22d62e72 100644 --- a/api/dev/states/myservers.cfg +++ b/api/dev/states/myservers.cfg @@ -1,5 +1,5 @@ [api] -version="4.1.3" +version="4.4.1" extraOrigins="https://google.com,https://test.com" [local] sandbox="yes" @@ -20,5 +20,5 @@ dynamicRemoteAccessType="DISABLED" ssoSubIds="" allowedOrigins="/var/run/unraid-notifications.sock, /var/run/unraid-php.sock, /var/run/unraid-cli.sock, http://localhost:8080, https://localhost:4443, https://tower.local:4443, https://192.168.1.150:4443, https://tower:4443, https://192-168-1-150.thisisfourtyrandomcharacters012345678900.myunraid.net:4443, https://85-121-123-122.thisisfourtyrandomcharacters012345678900.myunraid.net:8443, https://10-252-0-1.hash.myunraid.net:4443, https://10-252-1-1.hash.myunraid.net:4443, https://10-253-3-1.hash.myunraid.net:4443, https://10-253-4-1.hash.myunraid.net:4443, https://10-253-5-1.hash.myunraid.net:4443, https://10-100-0-1.hash.myunraid.net:4443, https://10-100-0-2.hash.myunraid.net:4443, https://10-123-1-2.hash.myunraid.net:4443, https://221-123-121-112.hash.myunraid.net:4443, https://google.com, https://test.com, https://connect.myunraid.net, https://connect-staging.myunraid.net, https://dev-my.myunraid.net:4000, https://studio.apollographql.com" [connectionStatus] -minigraph="PRE_INIT" +minigraph="ERROR_RETRYING" upnpStatus="" diff --git a/api/src/core/modules/array/get-array-data.ts b/api/src/core/modules/array/get-array-data.ts index 39bad51c8..20285aa0a 100644 --- a/api/src/core/modules/array/get-array-data.ts +++ b/api/src/core/modules/array/get-array-data.ts @@ -2,7 +2,6 @@ import { GraphQLError } from 'graphql'; import { sum } from 'lodash-es'; import type { ArrayCapacity, ArrayType } from '@app/graphql/generated/api/types.js'; -import { getServerIdentifier } from '@app/core/utils/server-identifier.js'; import { ArrayDiskType } from '@app/graphql/generated/api/types.js'; import { store } from '@app/store/index.js'; import { FileLoadStatus } from '@app/store/types.js'; diff --git a/api/src/core/utils/clients/emcmd.ts b/api/src/core/utils/clients/emcmd.ts index 8c8d65914..a7a112433 100644 --- a/api/src/core/utils/clients/emcmd.ts +++ b/api/src/core/utils/clients/emcmd.ts @@ -1,8 +1,8 @@ import { got } from 'got'; +import { AppError } from '@app/core/errors/app-error.js'; import { logger } from '@app/core/log.js'; import { type LooseObject } from '@app/core/types/index.js'; -import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; import { DRY_RUN } from '@app/environment.js'; import { getters } from '@app/store/index.js'; @@ -27,10 +27,15 @@ export const emcmd = async (commands: LooseObject) => { // Ensure we only log on dry-run return; } - // Untested, this code is unused right now so going to assume it's probably not working well anyway, swapped - // to got to remove this request-promise dependency return got - .get(url, { searchParams: { ...commands, csrf_token: csrfToken } }) - .catch(catchHandlers.emhttpd); - // return request.get(url, options).catch(catchHandlers.emhttpd); + .get(url, { + enableUnixSockets: true, + searchParams: { ...commands, csrf_token: csrfToken }, + }) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') { + throw new AppError('emhttpd socket unavailable.'); + } + throw error; + }); }; diff --git a/api/src/graphql/generated/api/operations.ts b/api/src/graphql/generated/api/operations.ts index f6b210861..cee791d49 100755 --- a/api/src/graphql/generated/api/operations.ts +++ b/api/src/graphql/generated/api/operations.ts @@ -2,7 +2,7 @@ import * as Types from '@app/graphql/generated/api/types.js'; import { z } from 'zod' -import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js' +import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskInput, ArrayDiskStatus, ArrayDiskType, ArrayMutations, ArrayMutationsaddDiskToArrayArgs, ArrayMutationsclearArrayDiskStatisticsArgs, ArrayMutationsmountArrayDiskArgs, ArrayMutationsremoveDiskFromArrayArgs, ArrayMutationssetStateArgs, ArrayMutationsunmountArrayDiskArgs, ArrayPendingState, ArrayState, ArrayStateInput, ArrayStateInputState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js' import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; type Properties = Required<{ @@ -25,6 +25,8 @@ export const ArrayPendingStateSchema = z.nativeEnum(ArrayPendingState); export const ArrayStateSchema = z.nativeEnum(ArrayState); +export const ArrayStateInputStateSchema = z.nativeEnum(ArrayStateInputState); + export const ConfigErrorStateSchema = z.nativeEnum(ConfigErrorState); export const ContainerPortTypeSchema = z.nativeEnum(ContainerPortType); @@ -213,6 +215,67 @@ export function ArrayDiskSchema(): z.ZodObject> { }) } +export function ArrayDiskInputSchema(): z.ZodObject> { + return z.object({ + id: z.string(), + slot: z.number().nullish() + }) +} + +export function ArrayMutationsSchema(): z.ZodObject> { + return z.object({ + __typename: z.literal('ArrayMutations').optional(), + addDiskToArray: ArrayTypeSchema().nullish(), + clearArrayDiskStatistics: z.record(z.string(), z.any()).nullish(), + mountArrayDisk: DiskSchema().nullish(), + removeDiskFromArray: ArrayTypeSchema().nullish(), + setState: ArrayTypeSchema().nullish(), + unmountArrayDisk: DiskSchema().nullish() + }) +} + +export function ArrayMutationsaddDiskToArrayArgsSchema(): z.ZodObject> { + return z.object({ + input: z.lazy(() => ArrayDiskInputSchema().nullish()) + }) +} + +export function ArrayMutationsclearArrayDiskStatisticsArgsSchema(): z.ZodObject> { + return z.object({ + id: z.string() + }) +} + +export function ArrayMutationsmountArrayDiskArgsSchema(): z.ZodObject> { + return z.object({ + id: z.string() + }) +} + +export function ArrayMutationsremoveDiskFromArrayArgsSchema(): z.ZodObject> { + return z.object({ + input: z.lazy(() => ArrayDiskInputSchema().nullish()) + }) +} + +export function ArrayMutationssetStateArgsSchema(): z.ZodObject> { + return z.object({ + input: z.lazy(() => ArrayStateInputSchema().nullish()) + }) +} + +export function ArrayMutationsunmountArrayDiskArgsSchema(): z.ZodObject> { + return z.object({ + id: z.string() + }) +} + +export function ArrayStateInputSchema(): z.ZodObject> { + return z.object({ + desiredState: z.lazy(() => ArrayStateInputStateSchema) + }) +} + export function BaseboardSchema(): z.ZodObject> { return z.object({ __typename: z.literal('Baseboard').optional(), @@ -1303,13 +1366,6 @@ export function addUserInputSchema(): z.ZodObject> { }) } -export function arrayDiskInputSchema(): z.ZodObject> { - return z.object({ - id: z.string(), - slot: z.number().nullish() - }) -} - export function deleteUserInputSchema(): z.ZodObject> { return z.object({ name: z.string() diff --git a/api/src/graphql/generated/api/types.ts b/api/src/graphql/generated/api/types.ts index 9c43ee19b..21c142754 100644 --- a/api/src/graphql/generated/api/types.ts +++ b/api/src/graphql/generated/api/types.ts @@ -191,6 +191,13 @@ export enum ArrayDiskFsColor { YELLOW_ON = 'yellow_on' } +export type ArrayDiskInput = { + /** Disk ID */ + id: Scalars['ID']['input']; + /** The slot for the disk */ + slot?: InputMaybe; +}; + export enum ArrayDiskStatus { /** disabled, old disk still present */ DISK_DSBL = 'DISK_DSBL', @@ -223,6 +230,49 @@ export enum ArrayDiskType { PARITY = 'Parity' } +export type ArrayMutations = { + __typename?: 'ArrayMutations'; + /** Add new disk to array */ + addDiskToArray?: Maybe; + clearArrayDiskStatistics?: Maybe; + mountArrayDisk?: Maybe; + /** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */ + removeDiskFromArray?: Maybe; + /** Set array state */ + setState?: Maybe; + unmountArrayDisk?: Maybe; +}; + + +export type ArrayMutationsaddDiskToArrayArgs = { + input?: InputMaybe; +}; + + +export type ArrayMutationsclearArrayDiskStatisticsArgs = { + id: Scalars['ID']['input']; +}; + + +export type ArrayMutationsmountArrayDiskArgs = { + id: Scalars['ID']['input']; +}; + + +export type ArrayMutationsremoveDiskFromArrayArgs = { + input?: InputMaybe; +}; + + +export type ArrayMutationssetStateArgs = { + input?: InputMaybe; +}; + + +export type ArrayMutationsunmountArrayDiskArgs = { + id: Scalars['ID']['input']; +}; + export enum ArrayPendingState { /** Array has no data disks */ NO_DATA_DISKS = 'no_data_disks', @@ -259,6 +309,18 @@ export enum ArrayState { TOO_MANY_MISSING_DISKS = 'TOO_MANY_MISSING_DISKS' } +export type ArrayStateInput = { + /** Array state */ + desiredState: ArrayStateInputState; +}; + +export enum ArrayStateInputState { + /** Start array */ + START = 'START', + /** Stop array */ + STOP = 'STOP' +} + export type Baseboard = { __typename?: 'Baseboard'; assetTag?: Maybe; @@ -731,8 +793,6 @@ export type Mount = { export type Mutation = { __typename?: 'Mutation'; - /** Add new disk to array */ - addDiskToArray?: Maybe; addPermission: Scalars['Boolean']['output']; addRoleForApiKey: Scalars['Boolean']['output']; addRoleForUser: Scalars['Boolean']['output']; @@ -742,9 +802,9 @@ export type Mutation = { /** Marks a notification as archived. */ archiveNotification: Notification; archiveNotifications: NotificationOverview; + array?: Maybe; /** Cancel parity check */ cancelParityCheck?: Maybe; - clearArrayDiskStatistics?: Maybe; connectSignIn: Scalars['Boolean']['output']; connectSignOut: Scalars['Boolean']['output']; createApiKey: ApiKeyWithSecret; @@ -756,29 +816,21 @@ export type Mutation = { deleteUser?: Maybe; enableDynamicRemoteAccess: Scalars['Boolean']['output']; login?: Maybe; - mountArrayDisk?: Maybe; /** Pause parity check */ pauseParityCheck?: Maybe; reboot?: Maybe; /** Reads each notification to recompute & update the overview. */ recalculateOverview: NotificationOverview; - /** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */ - removeDiskFromArray?: Maybe; removeRoleFromApiKey: Scalars['Boolean']['output']; /** Resume parity check */ resumeParityCheck?: Maybe; setAdditionalAllowedOrigins: Array; setupRemoteAccess: Scalars['Boolean']['output']; shutdown?: Maybe; - /** Start array */ - startArray?: Maybe; /** Start parity check */ startParityCheck?: Maybe; - /** Stop array */ - stopArray?: Maybe; unarchiveAll: NotificationOverview; unarchiveNotifications: NotificationOverview; - unmountArrayDisk?: Maybe; /** Marks a notification as unread. */ unreadNotification: Notification; /** @@ -789,11 +841,6 @@ export type Mutation = { }; -export type MutationaddDiskToArrayArgs = { - input?: InputMaybe; -}; - - export type MutationaddPermissionArgs = { input: AddPermissionInput; }; @@ -829,11 +876,6 @@ export type MutationarchiveNotificationsArgs = { }; -export type MutationclearArrayDiskStatisticsArgs = { - id: Scalars['ID']['input']; -}; - - export type MutationconnectSignInArgs = { input: ConnectSignInInput; }; @@ -871,16 +913,6 @@ export type MutationloginArgs = { }; -export type MutationmountArrayDiskArgs = { - id: Scalars['ID']['input']; -}; - - -export type MutationremoveDiskFromArrayArgs = { - input?: InputMaybe; -}; - - export type MutationremoveRoleFromApiKeyArgs = { input: RemoveRoleFromApiKeyInput; }; @@ -911,11 +943,6 @@ export type MutationunarchiveNotificationsArgs = { }; -export type MutationunmountArrayDiskArgs = { - id: Scalars['ID']['input']; -}; - - export type MutationunreadNotificationArgs = { id: Scalars['String']['input']; }; @@ -1823,13 +1850,6 @@ export type addUserInput = { password: Scalars['String']['input']; }; -export type arrayDiskInput = { - /** Disk ID */ - id: Scalars['ID']['input']; - /** The slot for the disk */ - slot?: InputMaybe; -}; - export type deleteUserInput = { name: Scalars['String']['input']; }; @@ -1945,10 +1965,14 @@ export type ResolversTypes = ResolversObject<{ ArrayCapacity: ResolverTypeWrapper; ArrayDisk: ResolverTypeWrapper; ArrayDiskFsColor: ArrayDiskFsColor; + ArrayDiskInput: ArrayDiskInput; ArrayDiskStatus: ArrayDiskStatus; ArrayDiskType: ArrayDiskType; + ArrayMutations: ResolverTypeWrapper; ArrayPendingState: ArrayPendingState; ArrayState: ArrayState; + ArrayStateInput: ArrayStateInput; + ArrayStateInputState: ArrayStateInputState; Baseboard: ResolverTypeWrapper; Boolean: ResolverTypeWrapper; Capacity: ResolverTypeWrapper; @@ -2057,7 +2081,6 @@ export type ResolversTypes = ResolversObject<{ WAN_FORWARD_TYPE: WAN_FORWARD_TYPE; Welcome: ResolverTypeWrapper; addUserInput: addUserInput; - arrayDiskInput: arrayDiskInput; deleteUserInput: deleteUserInput; mdState: mdState; registrationType: registrationType; @@ -2079,6 +2102,9 @@ export type ResolversParentTypes = ResolversObject<{ Array: ArrayType; ArrayCapacity: ArrayCapacity; ArrayDisk: ArrayDisk; + ArrayDiskInput: ArrayDiskInput; + ArrayMutations: ArrayMutations; + ArrayStateInput: ArrayStateInput; Baseboard: Baseboard; Boolean: Scalars['Boolean']['output']; Capacity: Capacity; @@ -2165,7 +2191,6 @@ export type ResolversParentTypes = ResolversObject<{ Vms: Vms; Welcome: Welcome; addUserInput: addUserInput; - arrayDiskInput: arrayDiskInput; deleteUserInput: deleteUserInput; usersInput: usersInput; }>; @@ -2250,6 +2275,16 @@ export type ArrayDiskResolvers; }>; +export type ArrayMutationsResolvers = ResolversObject<{ + addDiskToArray?: Resolver, ParentType, ContextType, Partial>; + clearArrayDiskStatistics?: Resolver, ParentType, ContextType, RequireFields>; + mountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; + removeDiskFromArray?: Resolver, ParentType, ContextType, Partial>; + setState?: Resolver, ParentType, ContextType, Partial>; + unmountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; + __isTypeOf?: IsTypeOfResolverFn; +}>; + export type BaseboardResolvers = ResolversObject<{ assetTag?: Resolver, ParentType, ContextType>; manufacturer?: Resolver; @@ -2612,7 +2647,6 @@ export type MountResolvers; export type MutationResolvers = ResolversObject<{ - addDiskToArray?: Resolver, ParentType, ContextType, Partial>; addPermission?: Resolver>; addRoleForApiKey?: Resolver>; addRoleForUser?: Resolver>; @@ -2620,8 +2654,8 @@ export type MutationResolvers>; archiveNotification?: Resolver>; archiveNotifications?: Resolver>; + array?: Resolver, ParentType, ContextType>; cancelParityCheck?: Resolver, ParentType, ContextType>; - clearArrayDiskStatistics?: Resolver, ParentType, ContextType, RequireFields>; connectSignIn?: Resolver>; connectSignOut?: Resolver; createApiKey?: Resolver>; @@ -2631,22 +2665,17 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; enableDynamicRemoteAccess?: Resolver>; login?: Resolver, ParentType, ContextType, RequireFields>; - mountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; pauseParityCheck?: Resolver, ParentType, ContextType>; reboot?: Resolver, ParentType, ContextType>; recalculateOverview?: Resolver; - removeDiskFromArray?: Resolver, ParentType, ContextType, Partial>; removeRoleFromApiKey?: Resolver>; resumeParityCheck?: Resolver, ParentType, ContextType>; setAdditionalAllowedOrigins?: Resolver, ParentType, ContextType, RequireFields>; setupRemoteAccess?: Resolver>; shutdown?: Resolver, ParentType, ContextType>; - startArray?: Resolver, ParentType, ContextType>; startParityCheck?: Resolver, ParentType, ContextType, Partial>; - stopArray?: Resolver, ParentType, ContextType>; unarchiveAll?: Resolver>; unarchiveNotifications?: Resolver>; - unmountArrayDisk?: Resolver, ParentType, ContextType, RequireFields>; unreadNotification?: Resolver>; updateApiSettings?: Resolver>; }>; @@ -3273,6 +3302,7 @@ export type Resolvers = ResolversObject<{ Array?: ArrayResolvers; ArrayCapacity?: ArrayCapacityResolvers; ArrayDisk?: ArrayDiskResolvers; + ArrayMutations?: ArrayMutationsResolvers; Baseboard?: BaseboardResolvers; Capacity?: CapacityResolvers; Case?: CaseResolvers; diff --git a/api/src/graphql/schema/types/array/array.graphql b/api/src/graphql/schema/types/array/array.graphql index a0e15747d..859fe83ad 100644 --- a/api/src/graphql/schema/types/array/array.graphql +++ b/api/src/graphql/schema/types/array/array.graphql @@ -3,16 +3,26 @@ type Query { array: Array! } -type Mutation { +enum ArrayStateInputState { """Start array""" - startArray: Array + START """Stop array""" - stopArray: Array + STOP +} + +input ArrayStateInput { + """Array state""" + desiredState: ArrayStateInputState! +} + +type ArrayMutations { + """Set array state""" + setState(input: ArrayStateInput): Array """Add new disk to array""" - addDiskToArray(input: arrayDiskInput): Array + addDiskToArray(input: ArrayDiskInput): Array """Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error.""" - removeDiskFromArray(input: arrayDiskInput): Array + removeDiskFromArray(input: ArrayDiskInput): Array mountArrayDisk(id: ID!): Disk unmountArrayDisk(id: ID!): Disk @@ -20,11 +30,15 @@ type Mutation { clearArrayDiskStatistics(id: ID!): JSON } +type Mutation { + array: ArrayMutations +} + type Subscription { array: Array! } -input arrayDiskInput { +input ArrayDiskInput { """Disk ID""" id: ID! """The slot for the disk""" diff --git a/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts new file mode 100644 index 000000000..2feb05177 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/array/array.mutations.resolver.ts @@ -0,0 +1,72 @@ +import { Args, ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz'; + +import type { ArrayDiskInput, ArrayStateInput } from '@app/graphql/generated/api/types.js'; +import { Resource } from '@app/graphql/generated/api/types.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; + +@Resolver('ArrayMutations') +export class ArrayMutationsResolver { + constructor(private readonly arrayService: ArrayService) {} + + @ResolveField('setState') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async setState(@Args('input') input: ArrayStateInput) { + return this.arrayService.updateArrayState(input); + } + + @ResolveField('addDiskToArray') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async addDiskToArray(@Args('input') input: ArrayDiskInput) { + return this.arrayService.addDiskToArray(input); + } + + @ResolveField('removeDiskFromArray') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async removeDiskFromArray(@Args('input') input: ArrayDiskInput) { + return this.arrayService.removeDiskFromArray(input); + } + + @ResolveField('mountArrayDisk') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async mountArrayDisk(@Args('id') id: string) { + return this.arrayService.mountArrayDisk(id); + } + + @ResolveField('unmountArrayDisk') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async unmountArrayDisk(@Args('id') id: string) { + return this.arrayService.unmountArrayDisk(id); + } + + @ResolveField('clearArrayDiskStatistics') + @UsePermissions({ + action: AuthActionVerb.UPDATE, + resource: Resource.ARRAY, + possession: AuthPossession.ANY, + }) + public async clearArrayDiskStatistics(@Args('id') id: string) { + return this.arrayService.clearArrayDiskStatistics(id); + } +} diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.spec.ts index 7f90be9c6..809ebd7cf 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.spec.ts @@ -1,19 +1,35 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; describe('ArrayResolver', () => { let resolver: ArrayResolver; + let arrayService: ArrayService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [ArrayResolver], + providers: [ + ArrayResolver, + { + provide: ArrayService, + useValue: { + updateArrayState: vi.fn(), + addDiskToArray: vi.fn(), + removeDiskFromArray: vi.fn(), + mountArrayDisk: vi.fn(), + unmountArrayDisk: vi.fn(), + clearArrayDiskStatistics: vi.fn(), + }, + }, + ], }).compile(); resolver = module.get(ArrayResolver); + arrayService = module.get(ArrayService); }); it('should be defined', () => { diff --git a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts index 3a4e7ba50..3b1c197ad 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.resolver.ts @@ -6,9 +6,12 @@ import { getArrayData } from '@app/core/modules/array/get-array-data.js'; import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { Resource } from '@app/graphql/generated/api/types.js'; import { store } from '@app/store/index.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; @Resolver('Array') export class ArrayResolver { + constructor(private readonly arrayService: ArrayService) {} + @Query() @UsePermissions({ action: AuthActionVerb.READ, diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts new file mode 100644 index 000000000..06a0901cd --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -0,0 +1,210 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ArrayDiskInput, ArrayStateInput } from '@app/graphql/generated/api/types.js'; +import { getArrayData } from '@app/core/modules/array/get-array-data.js'; +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { ArrayState, ArrayStateInputState } from '@app/graphql/generated/api/types.js'; +import { getters } from '@app/store/index.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; + +vi.mock('@app/core/utils/clients/emcmd.js', () => ({ + emcmd: vi.fn(), +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + emhttp: vi.fn(), + }, +})); + +vi.mock('@app/core/modules/array/get-array-data.js', () => ({ + getArrayData: vi.fn(), +})); + +describe('ArrayService', () => { + let service: ArrayService; + let mockArrayData: any; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ArrayService], + }).compile(); + + service = module.get(ArrayService); + + // Mock getters.emhttp() + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STOPPED, + }, + } as any); + + // Mock getArrayData + mockArrayData = { + id: 'array', + state: ArrayState.STOPPED, + capacity: { + kilobytes: { + free: '1000', + used: '1000', + total: '2000', + }, + disks: { + free: '10', + used: '5', + total: '15', + }, + }, + boot: null, + parities: [], + disks: [], + caches: [], + }; + vi.mocked(getArrayData).mockReturnValue(mockArrayData); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should update array state', async () => { + const input: ArrayStateInput = { + desiredState: ArrayStateInputState.START, + }; + const result = await service.updateArrayState(input); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + cmdStart: 'Start', + startState: 'STOPPED', + }); + }); + + it('should add disk to array', async () => { + const input: ArrayDiskInput = { + id: 'test-disk', + slot: 1, + }; + const result = await service.addDiskToArray(input); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + changeDevice: 'apply', + 'slotId.1': 'test-disk', + }); + }); + + it('should remove disk from array', async () => { + const input: ArrayDiskInput = { + id: 'test-disk', + slot: 1, + }; + const result = await service.removeDiskFromArray(input); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + changeDevice: 'apply', + 'slotId.1': '', + }); + }); + + it('should mount array disk', async () => { + // Mock array as running + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STARTED, + }, + } as any); + + const result = await service.mountArrayDisk('test-disk'); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + mount: 'apply', + 'diskId.test-disk': '1', + }); + }); + + it('should unmount array disk', async () => { + // Mock array as running + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STARTED, + }, + } as any); + + const result = await service.unmountArrayDisk('test-disk'); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + unmount: 'apply', + 'diskId.test-disk': '1', + }); + }); + + it('should clear array disk statistics', async () => { + // Mock array as running + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STARTED, + }, + } as any); + + const result = await service.clearArrayDiskStatistics('test-disk'); + expect(result).toEqual(mockArrayData); + expect(emcmd).toHaveBeenCalledWith({ + clearStats: 'apply', + 'diskId.test-disk': '1', + }); + }); + + it('should throw error when array is running for add disk', async () => { + // Mock array as running + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STARTED, + }, + } as any); + + const input: ArrayDiskInput = { + id: 'test-disk', + slot: 1, + }; + await expect(service.addDiskToArray(input)).rejects.toThrow( + 'Array needs to be stopped before any changes can occur.' + ); + }); + + it('should throw error when array is running for remove disk', async () => { + // Mock array as running + vi.mocked(getters.emhttp).mockReturnValue({ + var: { + mdState: ArrayState.STARTED, + }, + } as any); + + const input: ArrayDiskInput = { + id: 'test-disk', + slot: 1, + }; + await expect(service.removeDiskFromArray(input)).rejects.toThrow( + 'Array needs to be stopped before any changes can occur.' + ); + }); + + it('should throw error when array is not running for mount disk', async () => { + await expect(service.mountArrayDisk('test-disk')).rejects.toThrow( + 'Array must be running to mount disks' + ); + }); + + it('should throw error when array is not running for unmount disk', async () => { + await expect(service.unmountArrayDisk('test-disk')).rejects.toThrow( + 'Array must be running to unmount disks' + ); + }); + + it('should throw error when array is not running for clear disk statistics', async () => { + await expect(service.clearArrayDiskStatistics('test-disk')).rejects.toThrow( + 'Array must be running to clear disk statistics' + ); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.ts b/api/src/unraid-api/graph/resolvers/array/array.service.ts new file mode 100644 index 000000000..fe89a4bae --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/array/array.service.ts @@ -0,0 +1,145 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { capitalCase, constantCase } from 'change-case'; + +import type { ArrayDiskInput, ArrayStateInput, ArrayType } from '@app/graphql/generated/api/types.js'; +import { AppError } from '@app/core/errors/app-error.js'; +import { ArrayRunningError } from '@app/core/errors/array-running-error.js'; +import { getArrayData } from '@app/core/modules/array/get-array-data.js'; +import { emcmd } from '@app/core/utils/clients/emcmd.js'; +import { arrayIsRunning as arrayIsRunningUtil } from '@app/core/utils/index.js'; +import { + ArrayPendingState, + ArrayState, + ArrayStateInputState, +} from '@app/graphql/generated/api/types.js'; + +@Injectable() +export class ArrayService { + private pendingState: ArrayPendingState | null = null; + + /** + * Is the array running? + * @todo Refactor this to include this util in the service directly + */ + private arrayIsRunning() { + return arrayIsRunningUtil(); + } + + async updateArrayState({ desiredState }: ArrayStateInput): Promise { + const startState = this.arrayIsRunning() ? ArrayState.STARTED : ArrayState.STOPPED; + const pendingState = + desiredState === ArrayStateInputState.STOP + ? ArrayPendingState.STOPPING + : ArrayPendingState.STARTING; + + // Prevent this running multiple times at once + if (this.pendingState) { + throw new BadRequestException( + new AppError(`Array state is still being updated. Changing to ${pendingState}`) + ); + } + + // Prevent starting/stopping array when it's already in the same state + if ( + (this.arrayIsRunning() && desiredState === ArrayStateInputState.START) || + (!this.arrayIsRunning() && desiredState === ArrayStateInputState.STOP) + ) { + throw new BadRequestException(new AppError(`The array is already ${startState}`)); + } + + // Set lock then start/stop array + this.pendingState = pendingState; + const command = { + [`cmd${capitalCase(desiredState)}`]: capitalCase(desiredState), + startState: constantCase(startState), + }; + + try { + await emcmd(command); + } finally { + this.pendingState = null; + } + + // Get new array JSON + const array = getArrayData(); + + return array; + } + + async addDiskToArray(input: ArrayDiskInput): Promise { + if (this.arrayIsRunning()) { + throw new ArrayRunningError(); + } + + const { id: diskId, slot: preferredSlot } = input; + const slot = preferredSlot?.toString() ?? ''; + + // Add disk + await emcmd({ + changeDevice: 'apply', + [`slotId.${slot}`]: diskId, + }); + + return getArrayData(); + } + + async removeDiskFromArray(input: ArrayDiskInput): Promise { + if (this.arrayIsRunning()) { + throw new ArrayRunningError(); + } + + const { slot } = input; + const slotStr = slot?.toString() ?? ''; + + // Remove disk + await emcmd({ + changeDevice: 'apply', + [`slotId.${slotStr}`]: '', + }); + + return getArrayData(); + } + + async mountArrayDisk(id: string): Promise { + if (!this.arrayIsRunning()) { + throw new BadRequestException('Array must be running to mount disks'); + } + + // Mount disk + await emcmd({ + mount: 'apply', + [`diskId.${id}`]: '1', + }); + + return getArrayData(); + } + + async unmountArrayDisk(id: string): Promise { + if (!this.arrayIsRunning()) { + throw new BadRequestException('Array must be running to unmount disks'); + } + + // Unmount disk + await emcmd({ + unmount: 'apply', + [`diskId.${id}`]: '1', + }); + + return getArrayData(); + } + + async clearArrayDiskStatistics(id: string): Promise { + if (!this.arrayIsRunning()) { + throw new BadRequestException('Array must be running to clear disk statistics'); + } + + // Clear disk statistics + await emcmd({ + clearStats: 'apply', + [`diskId.${id}`]: '1', + }); + + return getArrayData(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts new file mode 100644 index 000000000..c7947793f --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -0,0 +1,11 @@ +import { ResolveField, Resolver } from '@nestjs/graphql'; + +@Resolver('Mutation') +export class MutationResolver { + @ResolveField() + public async array() { + return { + __typename: 'ArrayMutations', + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index e8492d5dd..31f47ac19 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -3,7 +3,9 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '@app/unraid-api/auth/auth.module.js'; import { ConnectSettingsService } from '@app/unraid-api/graph/connect/connect-settings.service.js'; import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js'; +import { ArrayMutationsResolver } from '@app/unraid-api/graph/resolvers/array/array.mutations.resolver.js'; import { ArrayResolver } from '@app/unraid-api/graph/resolvers/array/array.resolver.js'; +import { ArrayService } from '@app/unraid-api/graph/resolvers/array/array.service.js'; import { CloudResolver } from '@app/unraid-api/graph/resolvers/cloud/cloud.resolver.js'; import { ConfigResolver } from '@app/unraid-api/graph/resolvers/config/config.resolver.js'; import { DisksResolver } from '@app/unraid-api/graph/resolvers/disks/disks.resolver.js'; @@ -14,6 +16,7 @@ import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js'; import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js'; import { MeResolver } from '@app/unraid-api/graph/resolvers/me/me.resolver.js'; +import { MutationResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; @@ -27,6 +30,8 @@ import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js imports: [AuthModule], providers: [ ArrayResolver, + ArrayMutationsResolver, + ArrayService, ApiKeyResolver, CloudResolver, ConfigResolver, @@ -34,6 +39,7 @@ import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js DisplayResolver, DockerResolver, FlashResolver, + MutationResolver, InfoResolver, NotificationsResolver, OnlineResolver,