From a230c482f8b3afd2b1787eb5349fd459dd499ad0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 27 Mar 2026 11:09:28 -0400 Subject: [PATCH 1/4] feat: add server power control mutations (reboot/shutdown) New GraphQL mutations for server reboot and shutdown, replacing the legacy form-based Boot.php approach. Gated behind UPDATE_ANY CONFIG permissions. Co-Authored-By: Claude Opus 4.6 --- api/dev/configs/api.json | 4 +- api/generated-schema.graphql | 22 +++++++- api/src/unraid-api/cli/generated/graphql.ts | 23 +++++++++ .../resolvers/mutation/mutation.model.ts | 14 +++++ .../resolvers/mutation/mutation.resolver.ts | 6 +++ .../graph/resolvers/resolvers.module.ts | 4 ++ .../server-power.mutations.resolver.ts | 33 ++++++++++++ .../server-power/server-power.service.spec.ts | 51 +++++++++++++++++++ .../server-power/server-power.service.ts | 20 ++++++++ 9 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/server-power/server-power.mutations.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/server-power/server-power.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index d57ab4fd43..88063f95d8 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +1,9 @@ { - "version": "4.29.2", + "version": "4.31.1", "extraOrigins": [], "sandbox": false, "ssoSubIds": [], "plugins": [ "unraid-api-plugin-connect" ] -} +} \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 9fe342c7ba..2efbba0df0 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1210,6 +1210,16 @@ type ArrayMutations { input ArrayStateInput { """Array state""" desiredState: ArrayStateInputState! + + """ + Optional password used to unlock encrypted array disks when starting the array + """ + decryptionPassword: String + + """ + Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. + """ + decryptionKeyfile: String } enum ArrayStateInputState { @@ -1537,6 +1547,15 @@ input InstallPluginInput { forced: Boolean } +"""Server power control mutations""" +type ServerPowerMutations { + """Reboot the server""" + reboot: Boolean! + + """Shut down the server""" + shutdown: Boolean! +} + type Config implements Node { id: PrefixedID! valid: Boolean @@ -3304,6 +3323,7 @@ type Mutation { rclone: RCloneMutations! onboarding: OnboardingMutations! unraidPlugins: UnraidPluginsMutations! + serverPower: ServerPowerMutations! """Update server name, comment, and model""" updateServerIdentity(name: String!, comment: String, sysModel: String): Server! @@ -3609,4 +3629,4 @@ type Subscription { systemMetricsTemperature: TemperatureMetrics upsUpdates: UPSDevice! pluginInstallUpdates(operationId: ID!): PluginInstallEvent! -} +} \ No newline at end of file diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 0b0c497690..768d34fb15 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -366,6 +366,10 @@ export enum ArrayState { } export type ArrayStateInput = { + /** Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. */ + decryptionKeyfile?: InputMaybe; + /** Optional password used to unlock encrypted array disks when starting the array */ + decryptionPassword?: InputMaybe; /** Array state */ desiredState: ArrayStateInputState; }; @@ -1637,6 +1641,7 @@ export type Mutation = { renameDockerFolder: ResolvedOrganizerV1; /** Reset Docker template mappings to defaults. Use this to recover from corrupted state. */ resetDockerTemplateMappings: Scalars['Boolean']['output']; + serverPower: ServerPowerMutations; setDockerFolderChildren: ResolvedOrganizerV1; setupRemoteAccess: Scalars['Boolean']['output']; syncDockerTemplatePaths: DockerTemplateSyncResult; @@ -1985,12 +1990,21 @@ export type OnboardingInternalBootContext = { assignableDisks: Array; bootEligible?: Maybe; bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output']; + driveWarnings: Array; enableBootTransfer?: Maybe; poolNames: Array; reservedNames: Array; shareNames: Array; }; +/** Warning metadata for an assignable internal boot drive */ +export type OnboardingInternalBootDriveWarning = { + __typename?: 'OnboardingInternalBootDriveWarning'; + device: Scalars['String']['output']; + diskId: Scalars['String']['output']; + warnings: Array; +}; + /** Result of attempting internal boot pool setup */ export type OnboardingInternalBootResult = { __typename?: 'OnboardingInternalBootResult'; @@ -2634,6 +2648,15 @@ export type Server = Node & { wanip: Scalars['String']['output']; }; +/** Server power control mutations */ +export type ServerPowerMutations = { + __typename?: 'ServerPowerMutations'; + /** Reboot the server */ + reboot: Scalars['Boolean']['output']; + /** Shut down the server */ + shutdown: Scalars['Boolean']['output']; +}; + export enum ServerStatus { NEVER_CONNECTED = 'NEVER_CONNECTED', OFFLINE = 'OFFLINE', diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts index b56be8cc05..88115fc2ab 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -128,6 +128,17 @@ export class UnraidPluginsMutations { installLanguage!: PluginInstallOperation; } +@ObjectType({ + description: 'Server power control mutations', +}) +export class ServerPowerMutations { + @Field(() => Boolean, { description: 'Reboot the server' }) + reboot!: boolean; + + @Field(() => Boolean, { description: 'Shut down the server' }) + shutdown!: boolean; +} + @ObjectType() export class RootMutations { @Field(() => ArrayMutations, { description: 'Array related mutations' }) @@ -156,4 +167,7 @@ export class RootMutations { @Field(() => UnraidPluginsMutations, { description: 'Unraid plugin related mutations' }) unraidPlugins: UnraidPluginsMutations = new UnraidPluginsMutations(); + + @Field(() => ServerPowerMutations, { description: 'Server power control mutations' }) + serverPower: ServerPowerMutations = new ServerPowerMutations(); } diff --git a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts index 5ab6b2ad80..bdcd986574 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.resolver.ts @@ -9,6 +9,7 @@ import { ParityCheckMutations, RCloneMutations, RootMutations, + ServerPowerMutations, UnraidPluginsMutations, VmMutations, } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @@ -59,4 +60,9 @@ export class RootMutationsResolver { unraidPlugins(): UnraidPluginsMutations { return new UnraidPluginsMutations(); } + + @Mutation(() => ServerPowerMutations, { name: 'serverPower' }) + serverPower(): ServerPowerMutations { + return new ServerPowerMutations(); + } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index f912fea200..bb1f3a2d83 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -27,6 +27,8 @@ import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.re import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; import { RegistrationResolver } from '@app/unraid-api/graph/resolvers/registration/registration.resolver.js'; +import { ServerPowerMutationsResolver } from '@app/unraid-api/graph/resolvers/server-power/server-power.mutations.resolver.js'; +import { ServerPowerService } from '@app/unraid-api/graph/resolvers/server-power/server-power.service.js'; import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.resolver.js'; import { ServerService } from '@app/unraid-api/graph/resolvers/servers/server.service.js'; import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js'; @@ -81,6 +83,8 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; OnboardingQueryResolver, RegistrationResolver, RootMutationsResolver, + ServerPowerMutationsResolver, + ServerPowerService, ServerResolver, ServerService, ServicesResolver, diff --git a/api/src/unraid-api/graph/resolvers/server-power/server-power.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/server-power/server-power.mutations.resolver.ts new file mode 100644 index 0000000000..0cd5148efd --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/server-power/server-power.mutations.resolver.ts @@ -0,0 +1,33 @@ +import { ResolveField, Resolver } from '@nestjs/graphql'; + +import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; + +import { ServerPowerMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; +import { ServerPowerService } from '@app/unraid-api/graph/resolvers/server-power/server-power.service.js'; + +/** + * Nested Resolvers for Mutations MUST use @ResolveField() instead of @Mutation() + */ +@Resolver(() => ServerPowerMutations) +export class ServerPowerMutationsResolver { + constructor(private readonly serverPowerService: ServerPowerService) {} + + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + @ResolveField(() => Boolean, { description: 'Reboot the server' }) + async reboot(): Promise { + return this.serverPowerService.reboot(); + } + + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.CONFIG, + }) + @ResolveField(() => Boolean, { description: 'Shut down the server' }) + async shutdown(): Promise { + return this.serverPowerService.shutdown(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/server-power/server-power.service.spec.ts b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.spec.ts new file mode 100644 index 0000000000..419cee994d --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.spec.ts @@ -0,0 +1,51 @@ +import { execa } from 'execa'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ServerPowerService } from '@app/unraid-api/graph/resolvers/server-power/server-power.service.js'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +describe('ServerPowerService', () => { + let service: ServerPowerService; + + beforeEach(() => { + service = new ServerPowerService(); + vi.clearAllMocks(); + }); + + describe('reboot', () => { + it('calls /sbin/reboot with -n flag', async () => { + vi.mocked(execa).mockResolvedValueOnce({} as Awaited>); + + const result = await service.reboot(); + + expect(result).toBe(true); + expect(execa).toHaveBeenCalledWith('/sbin/reboot', ['-n']); + }); + + it('throws when exec fails', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('exec failed')); + + await expect(service.reboot()).rejects.toThrow(); + }); + }); + + describe('shutdown', () => { + it('calls /sbin/poweroff with -n flag', async () => { + vi.mocked(execa).mockResolvedValueOnce({} as Awaited>); + + const result = await service.shutdown(); + + expect(result).toBe(true); + expect(execa).toHaveBeenCalledWith('/sbin/poweroff', ['-n']); + }); + + it('throws when exec fails', async () => { + vi.mocked(execa).mockRejectedValueOnce(new Error('exec failed')); + + await expect(service.shutdown()).rejects.toThrow(); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts new file mode 100644 index 0000000000..34e706c27b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { execa } from 'execa'; + +@Injectable() +export class ServerPowerService { + private readonly logger = new Logger(ServerPowerService.name); + + async reboot(): Promise { + this.logger.log('Server reboot requested via GraphQL'); + await execa('/sbin/reboot', ['-n']); + return true; + } + + async shutdown(): Promise { + this.logger.log('Server shutdown requested via GraphQL'); + await execa('/sbin/poweroff', ['-n']); + return true; + } +} From 5103c707a1a1e043c5c5268984af5832edf65209 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 27 Mar 2026 11:09:47 -0400 Subject: [PATCH 2/4] refactor: use GraphQL mutations for server reboot/shutdown in onboarding Replace legacy form POST to Boot.php with proper GraphQL mutations. Adds noRetry context to prevent Apollo RetryLink from re-triggering power actions when the connection drops during reboot/shutdown. --- .../Onboarding/internalBoot.test.ts | 43 +++++++++------ .../Onboarding/composables/internalBoot.ts | 54 +++++++------------ .../graphql/serverPower.mutation.ts | 17 ++++++ web/src/composables/gql/gql.ts | 12 +++++ web/src/composables/gql/graphql.ts | 26 +++++++++ 5 files changed, 101 insertions(+), 51 deletions(-) create mode 100644 web/src/components/Onboarding/graphql/serverPower.mutation.ts diff --git a/web/__test__/components/Onboarding/internalBoot.test.ts b/web/__test__/components/Onboarding/internalBoot.test.ts index 200f576f24..747e18b706 100644 --- a/web/__test__/components/Onboarding/internalBoot.test.ts +++ b/web/__test__/components/Onboarding/internalBoot.test.ts @@ -4,8 +4,13 @@ import { applyInternalBootSelection, submitInternalBootCreation, submitInternalBootReboot, + submitInternalBootShutdown, summarizeInternalBootBiosLogs, } from '~/components/Onboarding/composables/internalBoot'; +import { + SERVER_REBOOT_MUTATION, + SERVER_SHUTDOWN_MUTATION, +} from '~/components/Onboarding/graphql/serverPower.mutation'; const mutateMock = vi.fn(); @@ -375,25 +380,31 @@ describe('internalBoot composable', () => { }); }); - it('submits reboot form with cmd and csrf token', () => { - const submitSpy = vi.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(() => undefined); + it('submits reboot via GraphQL mutation', async () => { + mutateMock.mockResolvedValue({ + data: { serverPower: { reboot: true } }, + }); - submitInternalBootReboot(); + await submitInternalBootReboot(); - expect(submitSpy).toHaveBeenCalledTimes(1); - const form = document.querySelector('form'); - expect(form).toBeTruthy(); - if (!form) { - return; - } + expect(mutateMock).toHaveBeenCalledWith({ + mutation: SERVER_REBOOT_MUTATION, + fetchPolicy: 'no-cache', + context: { noRetry: true }, + }); + }); - expect(form.method.toLowerCase()).toBe('post'); - expect(form.target).toBe('_top'); - expect(form.getAttribute('action')).toBe('/plugins/dynamix/include/Boot.php'); + it('submits shutdown via GraphQL mutation', async () => { + mutateMock.mockResolvedValue({ + data: { serverPower: { shutdown: true } }, + }); - const cmd = form.querySelector('input[name="cmd"]') as HTMLInputElement | null; - expect(cmd?.value).toBe('reboot'); - const csrf = form.querySelector('input[name="csrf_token"]') as HTMLInputElement | null; - expect(csrf?.value).toBe('csrf-token-value'); + await submitInternalBootShutdown(); + + expect(mutateMock).toHaveBeenCalledWith({ + mutation: SERVER_SHUTDOWN_MUTATION, + fetchPolicy: 'no-cache', + context: { noRetry: true }, + }); }); }); diff --git a/web/src/components/Onboarding/composables/internalBoot.ts b/web/src/components/Onboarding/composables/internalBoot.ts index 7879cd6b90..be5068ebb1 100644 --- a/web/src/components/Onboarding/composables/internalBoot.ts +++ b/web/src/components/Onboarding/composables/internalBoot.ts @@ -2,6 +2,10 @@ import { useApolloClient } from '@vue/apollo-composable'; import { buildOnboardingErrorDiagnostics } from '@/components/Onboarding/composables/onboardingErrorDiagnostics'; import { CREATE_INTERNAL_BOOT_POOL_MUTATION } from '@/components/Onboarding/graphql/createInternalBootPool.mutation'; +import { + SERVER_REBOOT_MUTATION, + SERVER_SHUTDOWN_MUTATION, +} from '@/components/Onboarding/graphql/serverPower.mutation'; import type { LogEntry } from '@/components/Onboarding/components/OnboardingConsole.vue'; @@ -44,15 +48,6 @@ interface InternalBootBiosLogSummary { failureLines: string[]; } -const readCsrfToken = (): string | null => { - const token = globalThis.csrf_token; - if (typeof token !== 'string') { - return null; - } - const trimmedToken = token.trim(); - return trimmedToken.length > 0 ? trimmedToken : null; -}; - export const submitInternalBootCreation = async ( selection: InternalBootSelection, options: SubmitInternalBootOptions = {} @@ -233,31 +228,20 @@ export const applyInternalBootSelection = async ( }; }; -const submitBootCommand = (command: 'reboot' | 'shutdown') => { - const form = document.createElement('form'); - form.method = 'POST'; - form.action = '/plugins/dynamix/include/Boot.php'; - form.target = '_top'; - form.style.display = 'none'; - - const cmd = document.createElement('input'); - cmd.type = 'hidden'; - cmd.name = 'cmd'; - cmd.value = command; - form.appendChild(cmd); - - const csrfToken = readCsrfToken(); - if (csrfToken) { - const csrf = document.createElement('input'); - csrf.type = 'hidden'; - csrf.name = 'csrf_token'; - csrf.value = csrfToken; - form.appendChild(csrf); - } - - document.body.appendChild(form); - form.submit(); +export const submitInternalBootReboot = async () => { + const apolloClient = useApolloClient().client; + await apolloClient.mutate({ + mutation: SERVER_REBOOT_MUTATION, + fetchPolicy: 'no-cache', + context: { noRetry: true }, + }); }; -export const submitInternalBootReboot = () => submitBootCommand('reboot'); -export const submitInternalBootShutdown = () => submitBootCommand('shutdown'); +export const submitInternalBootShutdown = async () => { + const apolloClient = useApolloClient().client; + await apolloClient.mutate({ + mutation: SERVER_SHUTDOWN_MUTATION, + fetchPolicy: 'no-cache', + context: { noRetry: true }, + }); +}; diff --git a/web/src/components/Onboarding/graphql/serverPower.mutation.ts b/web/src/components/Onboarding/graphql/serverPower.mutation.ts new file mode 100644 index 0000000000..d33522c5e9 --- /dev/null +++ b/web/src/components/Onboarding/graphql/serverPower.mutation.ts @@ -0,0 +1,17 @@ +import { graphql } from '~/composables/gql'; + +export const SERVER_REBOOT_MUTATION = graphql(/* GraphQL */ ` + mutation ServerReboot { + serverPower { + reboot + } + } +`); + +export const SERVER_SHUTDOWN_MUTATION = graphql(/* GraphQL */ ` + mutation ServerShutdown { + serverPower { + shutdown + } + } +`); diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts index b09e234a71..d9aab8a2b1 100644 --- a/web/src/composables/gql/gql.ts +++ b/web/src/composables/gql/gql.ts @@ -84,6 +84,8 @@ type Documents = { "\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": typeof types.PluginInstallUpdatesDocument, "\n mutation RefreshInternalBootContext {\n onboarding {\n refreshInternalBootContext {\n bootEligible\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n reservedNames\n shareNames\n poolNames\n driveWarnings {\n diskId\n device\n warnings\n }\n assignableDisks {\n id\n device\n size\n serialNum\n interfaceType\n }\n }\n }\n }\n": typeof types.RefreshInternalBootContextDocument, "\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": typeof types.ResumeOnboardingDocument, + "\n mutation ServerReboot {\n serverPower {\n reboot\n }\n }\n": typeof types.ServerRebootDocument, + "\n mutation ServerShutdown {\n serverPower {\n shutdown\n }\n }\n": typeof types.ServerShutdownDocument, "\n query TimeZoneOptions {\n timeZoneOptions {\n value\n label\n }\n }\n": typeof types.TimeZoneOptionsDocument, "\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": typeof types.UpdateSystemTimeDocument, "\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": typeof types.CreateRCloneRemoteDocument, @@ -172,6 +174,8 @@ const documents: Documents = { "\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": types.PluginInstallUpdatesDocument, "\n mutation RefreshInternalBootContext {\n onboarding {\n refreshInternalBootContext {\n bootEligible\n bootedFromFlashWithInternalBootSetup\n enableBootTransfer\n reservedNames\n shareNames\n poolNames\n driveWarnings {\n diskId\n device\n warnings\n }\n assignableDisks {\n id\n device\n size\n serialNum\n interfaceType\n }\n }\n }\n }\n": types.RefreshInternalBootContextDocument, "\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n": types.ResumeOnboardingDocument, + "\n mutation ServerReboot {\n serverPower {\n reboot\n }\n }\n": types.ServerRebootDocument, + "\n mutation ServerShutdown {\n serverPower {\n shutdown\n }\n }\n": types.ServerShutdownDocument, "\n query TimeZoneOptions {\n timeZoneOptions {\n value\n label\n }\n }\n": types.TimeZoneOptionsDocument, "\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": types.UpdateSystemTimeDocument, "\n mutation CreateRCloneRemote($input: CreateRCloneRemoteInput!) {\n rclone {\n createRCloneRemote(input: $input) {\n name\n type\n parameters\n }\n }\n }\n": types.CreateRCloneRemoteDocument, @@ -484,6 +488,14 @@ export function graphql(source: "\n mutation RefreshInternalBootContext {\n * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"): (typeof documents)["\n mutation ResumeOnboarding {\n onboarding {\n resumeOnboarding {\n status\n completed\n completedAtVersion\n shouldOpen\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation ServerReboot {\n serverPower {\n reboot\n }\n }\n"): (typeof documents)["\n mutation ServerReboot {\n serverPower {\n reboot\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n mutation ServerShutdown {\n serverPower {\n shutdown\n }\n }\n"): (typeof documents)["\n mutation ServerShutdown {\n serverPower {\n shutdown\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 91e8636a79..9699155dd7 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -366,6 +366,10 @@ export enum ArrayState { } export type ArrayStateInput = { + /** Optional keyfile contents used to unlock encrypted array disks when starting the array. Accepts a data URL or raw base64 payload. */ + decryptionKeyfile?: InputMaybe; + /** Optional password used to unlock encrypted array disks when starting the array */ + decryptionPassword?: InputMaybe; /** Array state */ desiredState: ArrayStateInputState; }; @@ -1637,6 +1641,7 @@ export type Mutation = { renameDockerFolder: ResolvedOrganizerV1; /** Reset Docker template mappings to defaults. Use this to recover from corrupted state. */ resetDockerTemplateMappings: Scalars['Boolean']['output']; + serverPower: ServerPowerMutations; setDockerFolderChildren: ResolvedOrganizerV1; setupRemoteAccess: Scalars['Boolean']['output']; syncDockerTemplatePaths: DockerTemplateSyncResult; @@ -2643,6 +2648,15 @@ export type Server = Node & { wanip: Scalars['String']['output']; }; +/** Server power control mutations */ +export type ServerPowerMutations = { + __typename?: 'ServerPowerMutations'; + /** Reboot the server */ + reboot: Scalars['Boolean']['output']; + /** Shut down the server */ + shutdown: Scalars['Boolean']['output']; +}; + export enum ServerStatus { NEVER_CONNECTED = 'NEVER_CONNECTED', OFFLINE = 'OFFLINE', @@ -3996,6 +4010,16 @@ export type ResumeOnboardingMutationVariables = Exact<{ [key: string]: never; }> export type ResumeOnboardingMutation = { __typename?: 'Mutation', onboarding: { __typename?: 'OnboardingMutations', resumeOnboarding: { __typename?: 'Onboarding', status: OnboardingStatus, completed: boolean, completedAtVersion?: string | null, shouldOpen: boolean } } }; +export type ServerRebootMutationVariables = Exact<{ [key: string]: never; }>; + + +export type ServerRebootMutation = { __typename?: 'Mutation', serverPower: { __typename?: 'ServerPowerMutations', reboot: boolean } }; + +export type ServerShutdownMutationVariables = Exact<{ [key: string]: never; }>; + + +export type ServerShutdownMutation = { __typename?: 'Mutation', serverPower: { __typename?: 'ServerPowerMutations', shutdown: boolean } }; + export type TimeZoneOptionsQueryVariables = Exact<{ [key: string]: never; }>; @@ -4157,6 +4181,8 @@ export const PluginInstallOperationDocument = {"kind":"Document","definitions":[ export const PluginInstallUpdatesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"PluginInstallUpdates"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"pluginInstallUpdates"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"operationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"operationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"operationId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"output"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}}]}}]}}]} as unknown as DocumentNode; export const RefreshInternalBootContextDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RefreshInternalBootContext"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshInternalBootContext"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bootEligible"}},{"kind":"Field","name":{"kind":"Name","value":"bootedFromFlashWithInternalBootSetup"}},{"kind":"Field","name":{"kind":"Name","value":"enableBootTransfer"}},{"kind":"Field","name":{"kind":"Name","value":"reservedNames"}},{"kind":"Field","name":{"kind":"Name","value":"shareNames"}},{"kind":"Field","name":{"kind":"Name","value":"poolNames"}},{"kind":"Field","name":{"kind":"Name","value":"driveWarnings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"diskId"}},{"kind":"Field","name":{"kind":"Name","value":"device"}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}},{"kind":"Field","name":{"kind":"Name","value":"assignableDisks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"device"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"serialNum"}},{"kind":"Field","name":{"kind":"Name","value":"interfaceType"}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const ResumeOnboardingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ResumeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"onboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resumeOnboarding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"completed"}},{"kind":"Field","name":{"kind":"Name","value":"completedAtVersion"}},{"kind":"Field","name":{"kind":"Name","value":"shouldOpen"}}]}}]}}]}}]} as unknown as DocumentNode; +export const ServerRebootDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ServerReboot"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverPower"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reboot"}}]}}]}}]} as unknown as DocumentNode; +export const ServerShutdownDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ServerShutdown"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverPower"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"shutdown"}}]}}]}}]} as unknown as DocumentNode; export const TimeZoneOptionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"TimeZoneOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"timeZoneOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]}}]} as unknown as DocumentNode; export const UpdateSystemTimeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateSystemTime"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateSystemTimeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateSystemTime"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentTime"}},{"kind":"Field","name":{"kind":"Name","value":"timeZone"}},{"kind":"Field","name":{"kind":"Name","value":"useNtp"}},{"kind":"Field","name":{"kind":"Name","value":"ntpServers"}}]}}]}}]} as unknown as DocumentNode; export const CreateRCloneRemoteDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateRCloneRemote"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRCloneRemoteInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"rclone"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createRCloneRemote"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"parameters"}}]}}]}}]}}]} as unknown as DocumentNode; From 4a910a6f35574e64df4e5b6ac30f66f24ece10c0 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 27 Mar 2026 11:09:59 -0400 Subject: [PATCH 3/4] chore: remove server lifecycle restriction from AGENTS.md --- AGENTS.md | 1 - 1 file changed, 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 57fa98e9ed..80bee6eb36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,6 @@ Enables GraphQL playground at `http://tower.local/graphql` - We are using tailwind v4 we do not need a tailwind config anymore - always search the internet for tailwind v4 documentation when making tailwind related style changes -- never run or restart the API server or web server. I will handle the lifecycle, simply wait and ask me to do this for you - Never use the `any` type. Always prefer proper typing - Avoid using casting whenever possible, prefer proper typing from the start - **IMPORTANT:** cache-manager v7 expects TTL values in **milliseconds**, not seconds. Always use milliseconds when setting cache TTL (e.g., 600000 for 10 minutes, not 600) From 79cdcb7a17e6897e82c63533540a84b4c4f90084 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Fri, 27 Mar 2026 11:28:29 -0400 Subject: [PATCH 4/4] docs: explain -n flag on reboot/poweroff matches webgui Boot.php --- .../graph/resolvers/server-power/server-power.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts index 34e706c27b..750e534dc8 100644 --- a/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts +++ b/api/src/unraid-api/graph/resolvers/server-power/server-power.service.ts @@ -6,6 +6,14 @@ import { execa } from 'execa'; export class ServerPowerService { private readonly logger = new Logger(ServerPowerService.name); + /** + * The -n flag skips a userspace sync in this process before signaling init. + * This matches webgui's Boot.php behavior. The real shutdown sequence (rc.6) + * handles all cleanup: stopping the array, unmounting filesystems, syncing, + * and remounting boot read-only before the final reboot/poweroff (which runs + * without -n, so the kernel syncs at that point too). + */ + async reboot(): Promise { this.logger.log('Server reboot requested via GraphQL'); await execa('/sbin/reboot', ['-n']);