feat: basic array controls (#1291)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Expanded API operations for array management, including new mutations
for state changes and disk handling.
- Introduced new enumeration and input types for managing array states
in the GraphQL schema.
- Added a new resolver for handling array mutations in the GraphQL API.

- **Chores**
- Upgraded configuration version to 4.4.1 and refined connectivity
status reporting.

- **Refactor**
- Streamlined request processing with improved error handling to provide
clearer feedback on connectivity issues.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-03-31 15:47:33 -04:00
committed by GitHub
parent b4c8efa483
commit 61fe6966ca
14 changed files with 645 additions and 78 deletions

View File

@@ -1,5 +1,5 @@
[api]
version="4.1.3"
version="4.4.1"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"

View File

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

View File

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

View File

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

View File

@@ -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<T> = 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<Properties<ArrayDisk>> {
})
}
export function ArrayDiskInputSchema(): z.ZodObject<Properties<ArrayDiskInput>> {
return z.object({
id: z.string(),
slot: z.number().nullish()
})
}
export function ArrayMutationsSchema(): z.ZodObject<Properties<ArrayMutations>> {
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<Properties<ArrayMutationsaddDiskToArrayArgs>> {
return z.object({
input: z.lazy(() => ArrayDiskInputSchema().nullish())
})
}
export function ArrayMutationsclearArrayDiskStatisticsArgsSchema(): z.ZodObject<Properties<ArrayMutationsclearArrayDiskStatisticsArgs>> {
return z.object({
id: z.string()
})
}
export function ArrayMutationsmountArrayDiskArgsSchema(): z.ZodObject<Properties<ArrayMutationsmountArrayDiskArgs>> {
return z.object({
id: z.string()
})
}
export function ArrayMutationsremoveDiskFromArrayArgsSchema(): z.ZodObject<Properties<ArrayMutationsremoveDiskFromArrayArgs>> {
return z.object({
input: z.lazy(() => ArrayDiskInputSchema().nullish())
})
}
export function ArrayMutationssetStateArgsSchema(): z.ZodObject<Properties<ArrayMutationssetStateArgs>> {
return z.object({
input: z.lazy(() => ArrayStateInputSchema().nullish())
})
}
export function ArrayMutationsunmountArrayDiskArgsSchema(): z.ZodObject<Properties<ArrayMutationsunmountArrayDiskArgs>> {
return z.object({
id: z.string()
})
}
export function ArrayStateInputSchema(): z.ZodObject<Properties<ArrayStateInput>> {
return z.object({
desiredState: z.lazy(() => ArrayStateInputStateSchema)
})
}
export function BaseboardSchema(): z.ZodObject<Properties<Baseboard>> {
return z.object({
__typename: z.literal('Baseboard').optional(),
@@ -1303,13 +1366,6 @@ export function addUserInputSchema(): z.ZodObject<Properties<addUserInput>> {
})
}
export function arrayDiskInputSchema(): z.ZodObject<Properties<arrayDiskInput>> {
return z.object({
id: z.string(),
slot: z.number().nullish()
})
}
export function deleteUserInputSchema(): z.ZodObject<Properties<deleteUserInput>> {
return z.object({
name: z.string()

View File

@@ -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<Scalars['Int']['input']>;
};
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<ArrayType>;
clearArrayDiskStatistics?: Maybe<Scalars['JSON']['output']>;
mountArrayDisk?: Maybe<Disk>;
/** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */
removeDiskFromArray?: Maybe<ArrayType>;
/** Set array state */
setState?: Maybe<ArrayType>;
unmountArrayDisk?: Maybe<Disk>;
};
export type ArrayMutationsaddDiskToArrayArgs = {
input?: InputMaybe<ArrayDiskInput>;
};
export type ArrayMutationsclearArrayDiskStatisticsArgs = {
id: Scalars['ID']['input'];
};
export type ArrayMutationsmountArrayDiskArgs = {
id: Scalars['ID']['input'];
};
export type ArrayMutationsremoveDiskFromArrayArgs = {
input?: InputMaybe<ArrayDiskInput>;
};
export type ArrayMutationssetStateArgs = {
input?: InputMaybe<ArrayStateInput>;
};
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<Scalars['String']['output']>;
@@ -731,8 +793,6 @@ export type Mount = {
export type Mutation = {
__typename?: 'Mutation';
/** Add new disk to array */
addDiskToArray?: Maybe<ArrayType>;
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<ArrayMutations>;
/** Cancel parity check */
cancelParityCheck?: Maybe<Scalars['JSON']['output']>;
clearArrayDiskStatistics?: Maybe<Scalars['JSON']['output']>;
connectSignIn: Scalars['Boolean']['output'];
connectSignOut: Scalars['Boolean']['output'];
createApiKey: ApiKeyWithSecret;
@@ -756,29 +816,21 @@ export type Mutation = {
deleteUser?: Maybe<User>;
enableDynamicRemoteAccess: Scalars['Boolean']['output'];
login?: Maybe<Scalars['String']['output']>;
mountArrayDisk?: Maybe<Disk>;
/** Pause parity check */
pauseParityCheck?: Maybe<Scalars['JSON']['output']>;
reboot?: Maybe<Scalars['String']['output']>;
/** 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<ArrayType>;
removeRoleFromApiKey: Scalars['Boolean']['output'];
/** Resume parity check */
resumeParityCheck?: Maybe<Scalars['JSON']['output']>;
setAdditionalAllowedOrigins: Array<Scalars['String']['output']>;
setupRemoteAccess: Scalars['Boolean']['output'];
shutdown?: Maybe<Scalars['String']['output']>;
/** Start array */
startArray?: Maybe<ArrayType>;
/** Start parity check */
startParityCheck?: Maybe<Scalars['JSON']['output']>;
/** Stop array */
stopArray?: Maybe<ArrayType>;
unarchiveAll: NotificationOverview;
unarchiveNotifications: NotificationOverview;
unmountArrayDisk?: Maybe<Disk>;
/** Marks a notification as unread. */
unreadNotification: Notification;
/**
@@ -789,11 +841,6 @@ export type Mutation = {
};
export type MutationaddDiskToArrayArgs = {
input?: InputMaybe<arrayDiskInput>;
};
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<arrayDiskInput>;
};
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<Scalars['Int']['input']>;
};
export type deleteUserInput = {
name: Scalars['String']['input'];
};
@@ -1945,10 +1965,14 @@ export type ResolversTypes = ResolversObject<{
ArrayCapacity: ResolverTypeWrapper<ArrayCapacity>;
ArrayDisk: ResolverTypeWrapper<ArrayDisk>;
ArrayDiskFsColor: ArrayDiskFsColor;
ArrayDiskInput: ArrayDiskInput;
ArrayDiskStatus: ArrayDiskStatus;
ArrayDiskType: ArrayDiskType;
ArrayMutations: ResolverTypeWrapper<ArrayMutations>;
ArrayPendingState: ArrayPendingState;
ArrayState: ArrayState;
ArrayStateInput: ArrayStateInput;
ArrayStateInputState: ArrayStateInputState;
Baseboard: ResolverTypeWrapper<Baseboard>;
Boolean: ResolverTypeWrapper<Scalars['Boolean']['output']>;
Capacity: ResolverTypeWrapper<Capacity>;
@@ -2057,7 +2081,6 @@ export type ResolversTypes = ResolversObject<{
WAN_FORWARD_TYPE: WAN_FORWARD_TYPE;
Welcome: ResolverTypeWrapper<Welcome>;
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<ContextType = Context, ParentType extends Resolve
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type ArrayMutationsResolvers<ContextType = Context, ParentType extends ResolversParentTypes['ArrayMutations'] = ResolversParentTypes['ArrayMutations']> = ResolversObject<{
addDiskToArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<ArrayMutationsaddDiskToArrayArgs>>;
clearArrayDiskStatistics?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<ArrayMutationsclearArrayDiskStatisticsArgs, 'id'>>;
mountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<ArrayMutationsmountArrayDiskArgs, 'id'>>;
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<ArrayMutationsremoveDiskFromArrayArgs>>;
setState?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<ArrayMutationssetStateArgs>>;
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<ArrayMutationsunmountArrayDiskArgs, 'id'>>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
}>;
export type BaseboardResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Baseboard'] = ResolversParentTypes['Baseboard']> = ResolversObject<{
assetTag?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
manufacturer?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -2612,7 +2647,6 @@ export type MountResolvers<ContextType = Context, ParentType extends ResolversPa
}>;
export type MutationResolvers<ContextType = Context, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = ResolversObject<{
addDiskToArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationaddDiskToArrayArgs>>;
addPermission?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddPermissionArgs, 'input'>>;
addRoleForApiKey?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddRoleForApiKeyArgs, 'input'>>;
addRoleForUser?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationaddRoleForUserArgs, 'input'>>;
@@ -2620,8 +2654,8 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
archiveAll?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationarchiveAllArgs>>;
archiveNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationarchiveNotificationArgs, 'id'>>;
archiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationarchiveNotificationsArgs>>;
array?: Resolver<Maybe<ResolversTypes['ArrayMutations']>, ParentType, ContextType>;
cancelParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
clearArrayDiskStatistics?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, RequireFields<MutationclearArrayDiskStatisticsArgs, 'id'>>;
connectSignIn?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationconnectSignInArgs, 'input'>>;
connectSignOut?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
createApiKey?: Resolver<ResolversTypes['ApiKeyWithSecret'], ParentType, ContextType, RequireFields<MutationcreateApiKeyArgs, 'input'>>;
@@ -2631,22 +2665,17 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
deleteUser?: Resolver<Maybe<ResolversTypes['User']>, ParentType, ContextType, RequireFields<MutationdeleteUserArgs, 'input'>>;
enableDynamicRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationenableDynamicRemoteAccessArgs, 'input'>>;
login?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationloginArgs, 'password' | 'username'>>;
mountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationmountArrayDiskArgs, 'id'>>;
pauseParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
reboot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
recalculateOverview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationremoveDiskFromArrayArgs>>;
removeRoleFromApiKey?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationremoveRoleFromApiKeyArgs, 'input'>>;
resumeParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
setAdditionalAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationsetAdditionalAllowedOriginsArgs, 'input'>>;
setupRemoteAccess?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType, RequireFields<MutationsetupRemoteAccessArgs, 'input'>>;
shutdown?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
startArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
startParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType, Partial<MutationstartParityCheckArgs>>;
stopArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType>;
unarchiveAll?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveAllArgs>>;
unarchiveNotifications?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType, Partial<MutationunarchiveNotificationsArgs>>;
unmountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationunmountArrayDiskArgs, 'id'>>;
unreadNotification?: Resolver<ResolversTypes['Notification'], ParentType, ContextType, RequireFields<MutationunreadNotificationArgs, 'id'>>;
updateApiSettings?: Resolver<ResolversTypes['ConnectSettingsValues'], ParentType, ContextType, RequireFields<MutationupdateApiSettingsArgs, 'input'>>;
}>;
@@ -3273,6 +3302,7 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
Array?: ArrayResolvers<ContextType>;
ArrayCapacity?: ArrayCapacityResolvers<ContextType>;
ArrayDisk?: ArrayDiskResolvers<ContextType>;
ArrayMutations?: ArrayMutationsResolvers<ContextType>;
Baseboard?: BaseboardResolvers<ContextType>;
Capacity?: CapacityResolvers<ContextType>;
Case?: CaseResolvers<ContextType>;

View File

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

View File

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

View File

@@ -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>(ArrayResolver);
arrayService = module.get<ArrayService>(ArrayService);
});
it('should be defined', () => {

View File

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

View File

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

View File

@@ -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<ArrayType> {
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<ArrayType> {
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<ArrayType> {
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<ArrayType> {
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<ArrayType> {
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<ArrayType> {
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();
}
}

View File

@@ -0,0 +1,11 @@
import { ResolveField, Resolver } from '@nestjs/graphql';
@Resolver('Mutation')
export class MutationResolver {
@ResolveField()
public async array() {
return {
__typename: 'ArrayMutations',
};
}
}

View File

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