Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 2 additions & 2 deletions api/dev/configs/api.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"version": "4.29.2",
"version": "4.31.1",
"extraOrigins": [],
"sandbox": false,
"ssoSubIds": [],
"plugins": [
"unraid-api-plugin-connect"
]
}
}
22 changes: 21 additions & 1 deletion api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!
Expand Down Expand Up @@ -3609,4 +3629,4 @@ type Subscription {
systemMetricsTemperature: TemperatureMetrics
upsUpdates: UPSDevice!
pluginInstallUpdates(operationId: ID!): PluginInstallEvent!
}
}
23 changes: 23 additions & 0 deletions api/src/unraid-api/cli/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Scalars['String']['input']>;
/** Optional password used to unlock encrypted array disks when starting the array */
decryptionPassword?: InputMaybe<Scalars['String']['input']>;
/** Array state */
desiredState: ArrayStateInputState;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1985,12 +1990,21 @@ export type OnboardingInternalBootContext = {
assignableDisks: Array<Disk>;
bootEligible?: Maybe<Scalars['Boolean']['output']>;
bootedFromFlashWithInternalBootSetup: Scalars['Boolean']['output'];
driveWarnings: Array<OnboardingInternalBootDriveWarning>;
enableBootTransfer?: Maybe<Scalars['String']['output']>;
poolNames: Array<Scalars['String']['output']>;
reservedNames: Array<Scalars['String']['output']>;
shareNames: Array<Scalars['String']['output']>;
};

/** Warning metadata for an assignable internal boot drive */
export type OnboardingInternalBootDriveWarning = {
__typename?: 'OnboardingInternalBootDriveWarning';
device: Scalars['String']['output'];
diskId: Scalars['String']['output'];
warnings: Array<Scalars['String']['output']>;
};

/** Result of attempting internal boot pool setup */
export type OnboardingInternalBootResult = {
__typename?: 'OnboardingInternalBootResult';
Expand Down Expand Up @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' })
Expand Down Expand Up @@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ParityCheckMutations,
RCloneMutations,
RootMutations,
ServerPowerMutations,
UnraidPluginsMutations,
VmMutations,
} from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
Expand Down Expand Up @@ -59,4 +60,9 @@ export class RootMutationsResolver {
unraidPlugins(): UnraidPluginsMutations {
return new UnraidPluginsMutations();
}

@Mutation(() => ServerPowerMutations, { name: 'serverPower' })
serverPower(): ServerPowerMutations {
return new ServerPowerMutations();
}
}
4 changes: 4 additions & 0 deletions api/src/unraid-api/graph/resolvers/resolvers.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,6 +83,8 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
OnboardingQueryResolver,
RegistrationResolver,
RootMutationsResolver,
ServerPowerMutationsResolver,
ServerPowerService,
ServerResolver,
ServerService,
ServicesResolver,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return this.serverPowerService.reboot();
}

@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
})
@ResolveField(() => Boolean, { description: 'Shut down the server' })
async shutdown(): Promise<boolean> {
return this.serverPowerService.shutdown();
}
}
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof execa>>);

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<ReturnType<typeof execa>>);

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();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Injectable, Logger } from '@nestjs/common';

import { execa } from 'execa';

@Injectable()
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<boolean> {
this.logger.log('Server reboot requested via GraphQL');
await execa('/sbin/reboot', ['-n']);
return true;
}

async shutdown(): Promise<boolean> {
this.logger.log('Server shutdown requested via GraphQL');
await execa('/sbin/poweroff', ['-n']);
return true;
}
}
43 changes: 27 additions & 16 deletions web/__test__/components/Onboarding/internalBoot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 },
});
});
});
Loading
Loading