feat(plugin): implement plugin installation tracking and management

- Introduced a new `UnraidPlugins` module to manage plugin installations, including tracking installation progress and status updates.
- Added GraphQL types and mutations for installing plugins, retrieving installation operations, and subscribing to installation updates.
- Enhanced the `ActivationPluginsStep` component to support real-time feedback during plugin installations, displaying logs and status messages.
- Updated localization files to include messages related to plugin installation processes.
- Implemented unit tests for the new plugin installation service and component interactions, ensuring reliability and correctness.

This update significantly enhances the user experience by providing a robust mechanism for managing plugin installations, improving the overall onboarding process.
This commit is contained in:
Eli Bosley
2025-10-15 17:23:02 -04:00
parent d4e93f1fd3
commit da381b5f95
20 changed files with 1264 additions and 37 deletions

View File

@@ -779,37 +779,6 @@ type ActivationOnboarding {
steps: [ActivationOnboardingStep!]!
}
type Theme {
"""The theme name"""
name: ThemeName!
"""Whether to show the header banner image"""
showBannerImage: Boolean!
"""Whether to show the banner gradient"""
showBannerGradient: Boolean!
"""Whether to show the description in the header"""
showHeaderDescription: Boolean!
"""The background color of the header"""
headerBackgroundColor: String
"""The text color of the header"""
headerPrimaryTextColor: String
"""The secondary text color of the header"""
headerSecondaryTextColor: String
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
type SsoSettings implements Node {
id: PrefixedID!
@@ -996,6 +965,58 @@ type RCloneRemote {
config: JSON!
}
"""Represents a tracked plugin installation operation"""
type PluginInstallOperation {
"""Unique identifier of the operation"""
id: ID!
"""Plugin URL passed to the installer"""
url: String!
"""Optional plugin name for display purposes"""
name: String
"""Current status of the operation"""
status: PluginInstallStatus!
"""Timestamp when the operation was created"""
createdAt: DateTime!
"""Timestamp for the last update to this operation"""
updatedAt: DateTime
"""Timestamp when the operation finished, if applicable"""
finishedAt: DateTime
"""
Collected output lines generated by the installer (capped at recent lines)
"""
output: [String!]!
}
"""Status of a plugin installation operation"""
enum PluginInstallStatus {
QUEUED
RUNNING
SUCCEEDED
FAILED
}
"""Emitted event representing progress for a plugin installation"""
type PluginInstallEvent {
"""Identifier of the related plugin installation operation"""
operationId: ID!
"""Status reported with this event"""
status: PluginInstallStatus!
"""Output lines newly emitted since the previous event"""
output: [String!]
"""Timestamp when the event was emitted"""
timestamp: DateTime!
}
type ArrayMutations {
"""Set array state"""
setState(input: ArrayStateInput!): UnraidArray!
@@ -1215,12 +1236,63 @@ input CompleteUpgradeStepInput {
stepId: ActivationOnboardingStepId!
}
"""Unraid plugin management mutations"""
type UnraidPluginsMutations {
"""Install an Unraid plugin and track installation progress"""
installPlugin(input: InstallPluginInput!): PluginInstallOperation!
}
"""Input payload for installing a plugin"""
input InstallPluginInput {
"""Plugin installation URL (.plg)"""
url: String!
"""Optional human-readable plugin name used for logging"""
name: String
"""
Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour.
"""
forced: Boolean
}
type Config implements Node {
id: PrefixedID!
valid: Boolean
error: String
}
type Theme {
"""The theme name"""
name: ThemeName!
"""Whether to show the header banner image"""
showBannerImage: Boolean!
"""Whether to show the banner gradient"""
showBannerGradient: Boolean!
"""Whether to show the description in the header"""
showHeaderDescription: Boolean!
"""The background color of the header"""
headerBackgroundColor: String
"""The text color of the header"""
headerPrimaryTextColor: String
"""The secondary text color of the header"""
headerSecondaryTextColor: String
}
"""The theme name"""
enum ThemeName {
azure
black
gray
white
}
type ExplicitStatusItem {
name: String!
updateStatus: UpdateStatus!
@@ -2741,6 +2813,12 @@ type Query {
upsDeviceById(id: String!): UPSDevice
upsConfiguration: UPSConfiguration!
"""Retrieve a plugin installation operation by identifier"""
pluginInstallOperation(operationId: ID!): PluginInstallOperation
"""List all tracked plugin installation operations"""
pluginInstallOperations: [PluginInstallOperation!]!
"""List all installed plugins with their metadata"""
plugins: [Plugin!]!
remoteAccess: RemoteAccess!
@@ -2782,6 +2860,7 @@ type Mutation {
customization: CustomizationMutations!
rclone: RCloneMutations!
onboarding: OnboardingMutations!
unraidPlugins: UnraidPluginsMutations!
createDockerFolder(name: String!, parentId: String, childrenIds: [String!]): ResolvedOrganizerV1!
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
@@ -3029,4 +3108,5 @@ type Subscription {
systemMetricsCpuTelemetry: CpuPackages!
systemMetricsMemory: MemoryUtilization!
upsUpdates: UPSDevice!
pluginInstallUpdates(operationId: ID!): PluginInstallEvent!
}

View File

@@ -1363,6 +1363,16 @@ export type InitiateFlashBackupInput = {
sourcePath: Scalars['String']['input'];
};
/** Input payload for installing a plugin */
export type InstallPluginInput = {
/** Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour. */
forced?: InputMaybe<Scalars['Boolean']['input']>;
/** Optional human-readable plugin name used for logging */
name?: InputMaybe<Scalars['String']['input']>;
/** Plugin installation URL (.plg) */
url: Scalars['String']['input'];
};
export type KeyFile = {
__typename?: 'KeyFile';
contents?: Maybe<Scalars['String']['output']>;
@@ -1519,6 +1529,7 @@ export type Mutation = {
syncDockerTemplatePaths: DockerTemplateSyncResult;
unarchiveAll: NotificationOverview;
unarchiveNotifications: NotificationOverview;
unraidPlugins: UnraidPluginsMutations;
/** Marks a notification as unread. */
unreadNotification: Notification;
updateApiSettings: ConnectSettingsValues;
@@ -1923,6 +1934,48 @@ export type Plugin = {
version: Scalars['String']['output'];
};
/** Emitted event representing progress for a plugin installation */
export type PluginInstallEvent = {
__typename?: 'PluginInstallEvent';
/** Identifier of the related plugin installation operation */
operationId: Scalars['ID']['output'];
/** Output lines newly emitted since the previous event */
output?: Maybe<Array<Scalars['String']['output']>>;
/** Status reported with this event */
status: PluginInstallStatus;
/** Timestamp when the event was emitted */
timestamp: Scalars['DateTime']['output'];
};
/** Represents a tracked plugin installation operation */
export type PluginInstallOperation = {
__typename?: 'PluginInstallOperation';
/** Timestamp when the operation was created */
createdAt: Scalars['DateTime']['output'];
/** Timestamp when the operation finished, if applicable */
finishedAt?: Maybe<Scalars['DateTime']['output']>;
/** Unique identifier of the operation */
id: Scalars['ID']['output'];
/** Optional plugin name for display purposes */
name?: Maybe<Scalars['String']['output']>;
/** Collected output lines generated by the installer (capped at recent lines) */
output: Array<Scalars['String']['output']>;
/** Current status of the operation */
status: PluginInstallStatus;
/** Timestamp for the last update to this operation */
updatedAt?: Maybe<Scalars['DateTime']['output']>;
/** Plugin URL passed to the installer */
url: Scalars['String']['output'];
};
/** Status of a plugin installation operation */
export enum PluginInstallStatus {
FAILED = 'FAILED',
QUEUED = 'QUEUED',
RUNNING = 'RUNNING',
SUCCEEDED = 'SUCCEEDED'
}
export type PluginManagementInput = {
/** Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only. */
bundled?: Scalars['Boolean']['input'];
@@ -2004,6 +2057,10 @@ export type Query = {
online: Scalars['Boolean']['output'];
owner: Owner;
parityHistory: Array<ParityCheck>;
/** Retrieve a plugin installation operation by identifier */
pluginInstallOperation?: Maybe<PluginInstallOperation>;
/** List all tracked plugin installation operations */
pluginInstallOperations: Array<PluginInstallOperation>;
/** List all installed plugins with their metadata */
plugins: Array<Plugin>;
/** Preview the effective permissions for a combination of roles and explicit permissions */
@@ -2060,6 +2117,11 @@ export type QueryOidcProviderArgs = {
};
export type QueryPluginInstallOperationArgs = {
operationId: Scalars['ID']['input'];
};
export type QueryPreviewEffectivePermissionsArgs = {
permissions?: InputMaybe<Array<AddPermissionInput>>;
roles?: InputMaybe<Array<Role>>;
@@ -2361,6 +2423,7 @@ export type Subscription = {
notificationsWarningsAndAlerts: Array<Notification>;
ownerSubscription: Owner;
parityHistorySubscription: ParityCheck;
pluginInstallUpdates: PluginInstallEvent;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
@@ -2373,6 +2436,11 @@ export type SubscriptionLogFileArgs = {
path: Scalars['String']['input'];
};
export type SubscriptionPluginInstallUpdatesArgs = {
operationId: Scalars['ID']['input'];
};
/** System time configuration and current status */
export type SystemTime = {
__typename?: 'SystemTime';
@@ -2630,6 +2698,19 @@ export type UnraidArray = Node & {
state: ArrayState;
};
/** Unraid plugin management mutations */
export type UnraidPluginsMutations = {
__typename?: 'UnraidPluginsMutations';
/** Install an Unraid plugin and track installation progress */
installPlugin: PluginInstallOperation;
};
/** Unraid plugin management mutations */
export type UnraidPluginsMutationsInstallPluginArgs = {
input: InstallPluginInput;
};
export type UpdateApiKeyInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['PrefixedID']['input'];

View File

@@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UpgradeInfo } from '@app/unraid-api/graph/resolvers/info/versions/versions.model.js';
import { RCloneRemote } from '@app/unraid-api/graph/resolvers/rclone/rclone.model.js';
import { PluginInstallOperation } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js';
/**
* Important:
@@ -56,6 +57,16 @@ export class OnboardingMutations {
completeUpgradeStep!: UpgradeInfo;
}
@ObjectType({
description: 'Unraid plugin management mutations',
})
export class UnraidPluginsMutations {
@Field(() => PluginInstallOperation, {
description: 'Install an Unraid plugin and track installation progress',
})
installPlugin!: PluginInstallOperation;
}
@ObjectType()
export class RootMutations {
@Field(() => ArrayMutations, { description: 'Array related mutations' })
@@ -81,4 +92,7 @@ export class RootMutations {
@Field(() => OnboardingMutations, { description: 'Onboarding related mutations' })
onboarding: OnboardingMutations = new OnboardingMutations();
@Field(() => UnraidPluginsMutations, { description: 'Unraid plugin related mutations' })
unraidPlugins: UnraidPluginsMutations = new UnraidPluginsMutations();
}

View File

@@ -9,6 +9,7 @@ import {
ParityCheckMutations,
RCloneMutations,
RootMutations,
UnraidPluginsMutations,
VmMutations,
} from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
@@ -53,4 +54,9 @@ export class RootMutationsResolver {
onboarding(): OnboardingMutations {
return new OnboardingMutations();
}
@Mutation(() => UnraidPluginsMutations, { name: 'unraidPlugins' })
unraidPlugins(): UnraidPluginsMutations {
return new UnraidPluginsMutations();
}
}

View File

@@ -26,6 +26,7 @@ import { ServerResolver } from '@app/unraid-api/graph/resolvers/servers/server.r
import { SettingsModule } from '@app/unraid-api/graph/resolvers/settings/settings.module.js';
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
import { SystemTimeModule } from '@app/unraid-api/graph/resolvers/system-time/system-time.module.js';
import { UnraidPluginsModule } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.js';
import { UPSModule } from '@app/unraid-api/graph/resolvers/ups/ups.module.js';
import { VarsResolver } from '@app/unraid-api/graph/resolvers/vars/vars.resolver.js';
import { VmMutationsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.mutations.resolver.js';
@@ -56,6 +57,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
MetricsModule,
SystemTimeModule,
UPSModule,
UnraidPluginsModule,
],
providers: [
ConfigResolver,

View File

@@ -0,0 +1,114 @@
import { Field, GraphQLISODateTime, ID, InputType, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IsBoolean, IsOptional, IsString, IsUrl } from 'class-validator';
export enum PluginInstallStatus {
QUEUED = 'QUEUED',
RUNNING = 'RUNNING',
SUCCEEDED = 'SUCCEEDED',
FAILED = 'FAILED',
}
registerEnumType(PluginInstallStatus, {
name: 'PluginInstallStatus',
description: 'Status of a plugin installation operation',
});
@InputType({
description: 'Input payload for installing a plugin',
})
export class InstallPluginInput {
@Field(() => String, {
description: 'Plugin installation URL (.plg)',
})
@IsUrl({
protocols: ['http', 'https'],
require_protocol: true,
})
url!: string;
@Field(() => String, {
nullable: true,
description: 'Optional human-readable plugin name used for logging',
})
@IsOptional()
@IsString()
name?: string | null;
@Field(() => Boolean, {
nullable: true,
description:
'Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour.',
})
@IsOptional()
@IsBoolean()
forced?: boolean | null;
}
@ObjectType({
description: 'Represents a tracked plugin installation operation',
})
export class PluginInstallOperation {
@Field(() => ID, {
description: 'Unique identifier of the operation',
})
id!: string;
@Field(() => String, {
description: 'Plugin URL passed to the installer',
})
url!: string;
@Field(() => String, {
nullable: true,
description: 'Optional plugin name for display purposes',
})
name?: string | null;
@Field(() => PluginInstallStatus, {
description: 'Current status of the operation',
})
status!: PluginInstallStatus;
@Field(() => GraphQLISODateTime, {
description: 'Timestamp when the operation was created',
})
createdAt!: Date;
@Field(() => GraphQLISODateTime, {
nullable: true,
description: 'Timestamp for the last update to this operation',
})
updatedAt?: Date | null;
@Field(() => GraphQLISODateTime, {
nullable: true,
description: 'Timestamp when the operation finished, if applicable',
})
finishedAt?: Date | null;
@Field(() => [String], {
description: 'Collected output lines generated by the installer (capped at recent lines)',
})
output!: string[];
}
@ObjectType({
description: 'Emitted event representing progress for a plugin installation',
})
export class PluginInstallEvent {
@Field(() => ID, { description: 'Identifier of the related plugin installation operation' })
operationId!: string;
@Field(() => PluginInstallStatus, { description: 'Status reported with this event' })
status!: PluginInstallStatus;
@Field(() => [String], {
nullable: true,
description: 'Output lines newly emitted since the previous event',
})
output?: string[] | null;
@Field(() => GraphQLISODateTime, { description: 'Timestamp when the event was emitted' })
timestamp!: Date;
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UnraidPluginsMutationsResolver } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.js';
import { UnraidPluginsResolver } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.js';
import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js';
@Module({
providers: [UnraidPluginsMutationsResolver, UnraidPluginsResolver, UnraidPluginsService],
exports: [UnraidPluginsService],
})
export class UnraidPluginsModule {}

View File

@@ -0,0 +1,27 @@
import { Args, ResolveField, Resolver } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { UnraidPluginsMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
import {
InstallPluginInput,
PluginInstallOperation,
} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js';
import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js';
@Resolver(() => UnraidPluginsMutations)
export class UnraidPluginsMutationsResolver {
constructor(private readonly pluginsService: UnraidPluginsService) {}
@ResolveField(() => PluginInstallOperation, {
description: 'Installs an Unraid plugin and begins tracking its progress',
})
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.CONFIG,
})
async installPlugin(@Args('input') input: InstallPluginInput): Promise<PluginInstallOperation> {
return this.pluginsService.installPlugin(input);
}
}

View File

@@ -0,0 +1,54 @@
import { Args, ID, Query, Resolver, Subscription } from '@nestjs/graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import {
PluginInstallEvent,
PluginInstallOperation,
} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js';
import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js';
@Resolver()
export class UnraidPluginsResolver {
constructor(private readonly pluginsService: UnraidPluginsService) {}
@Query(() => PluginInstallOperation, {
nullable: true,
description: 'Retrieve a plugin installation operation by identifier',
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
async pluginInstallOperation(
@Args('operationId', { type: () => ID }) operationId: string
): Promise<PluginInstallOperation | null> {
return this.pluginsService.getOperation(operationId);
}
@Query(() => [PluginInstallOperation], {
description: 'List all tracked plugin installation operations',
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
async pluginInstallOperations(): Promise<PluginInstallOperation[]> {
return this.pluginsService.listOperations();
}
@Subscription(() => PluginInstallEvent, {
name: 'pluginInstallUpdates',
resolve: (payload: { pluginInstallUpdates: PluginInstallEvent }) => payload.pluginInstallUpdates,
})
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.CONFIG,
})
pluginInstallUpdates(
@Args('operationId', { type: () => ID }) operationId: string
): AsyncIterableIterator<{ pluginInstallUpdates: PluginInstallEvent }> {
return this.pluginsService.subscribe(operationId);
}
}

View File

@@ -0,0 +1,112 @@
import EventEmitter from 'node:events';
import { PassThrough } from 'node:stream';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { pubsub } from '@app/core/pubsub.js';
import { PluginInstallStatus } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js';
import { UnraidPluginsService } from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.js';
class MockExecaProcess extends EventEmitter {
public readonly all = new PassThrough();
}
const mockExeca = vi.fn();
vi.mock('execa', () => ({
execa: (...args: unknown[]) => mockExeca(...args),
}));
const flushAsync = () => new Promise((resolve) => setTimeout(resolve, 0));
describe('UnraidPluginsService', () => {
let service: UnraidPluginsService;
let currentProcess: MockExecaProcess;
beforeEach(() => {
service = new UnraidPluginsService();
currentProcess = new MockExecaProcess();
currentProcess.all.setEncoding('utf-8');
mockExeca.mockReset();
mockExeca.mockImplementation(() => currentProcess as unknown as any);
vi.spyOn(pubsub, 'publish').mockClear();
});
const emitSuccess = (process: MockExecaProcess, lines: string[]) => {
lines.forEach((line) => process.all.write(`${line}\n`));
process.all.end();
process.emit('close', 0);
};
const emitFailure = (process: MockExecaProcess, errorMessage: string) => {
process.all.write(`${errorMessage}\n`);
process.all.end();
process.emit('close', 1);
};
it('installs plugin successfully and captures output', async () => {
const publishSpy = vi.spyOn(pubsub, 'publish');
const operation = await service.installPlugin({
url: 'https://example.com/plugin.plg',
name: 'Example Plugin',
});
expect(mockExeca).toHaveBeenCalledWith(
'plugin',
['install', 'https://example.com/plugin.plg', 'forced'],
{
all: true,
reject: false,
timeout: 5 * 60 * 1000,
}
);
const runningOperation = service.getOperation(operation.id);
expect(runningOperation?.status).toBe(PluginInstallStatus.RUNNING);
emitSuccess(currentProcess, ['Downloading package', 'Installation complete']);
await flushAsync();
const completedOperation = service.getOperation(operation.id);
expect(completedOperation?.status).toBe(PluginInstallStatus.SUCCEEDED);
expect(completedOperation?.output).toEqual(['Downloading package', 'Installation complete']);
expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), {
pluginInstallUpdates: expect.objectContaining({
operationId: operation.id,
status: PluginInstallStatus.RUNNING,
}),
});
expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), {
pluginInstallUpdates: expect.objectContaining({
operationId: operation.id,
status: PluginInstallStatus.SUCCEEDED,
}),
});
});
it('marks installation as failed on non-zero exit', async () => {
const publishSpy = vi.spyOn(pubsub, 'publish');
const operation = await service.installPlugin({
url: 'https://example.com/plugin.plg',
name: 'Broken Plugin',
});
emitFailure(currentProcess, 'Installation failed');
await flushAsync();
const failedOperation = service.getOperation(operation.id);
expect(failedOperation?.status).toBe(PluginInstallStatus.FAILED);
expect(failedOperation?.output.some((line) => line.includes('Installation failed'))).toBe(true);
expect(publishSpy).toHaveBeenCalledWith(expect.stringContaining(operation.id), {
pluginInstallUpdates: expect.objectContaining({
operationId: operation.id,
status: PluginInstallStatus.FAILED,
}),
});
});
});

View File

@@ -0,0 +1,313 @@
import { Injectable, Logger } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import { createSubscription, pubsub } from '@app/core/pubsub.js';
import {
InstallPluginInput,
PluginInstallEvent,
PluginInstallOperation,
PluginInstallStatus,
} from '@app/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.js';
const CHANNEL_PREFIX = 'PLUGIN_INSTALL:';
type PluginInstallSubscriberIterator = AsyncIterableIterator<{
pluginInstallUpdates: PluginInstallEvent;
}>;
type PluginInstallChildProcess = ReturnType<typeof execa>;
interface OperationState {
id: string;
url: string;
name?: string | null;
status: PluginInstallStatus;
createdAt: Date;
updatedAt?: Date;
finishedAt?: Date;
output: string[];
bufferedOutput: string;
forced: boolean;
child?: PluginInstallChildProcess;
}
@Injectable()
export class UnraidPluginsService {
private readonly logger = new Logger(UnraidPluginsService.name);
private readonly operations = new Map<string, OperationState>();
private readonly MAX_OUTPUT_LINES = 500;
async installPlugin(input: InstallPluginInput): Promise<PluginInstallOperation> {
const id = randomUUID();
const createdAt = new Date();
const operation: OperationState = {
id,
url: input.url,
name: input.name,
status: PluginInstallStatus.RUNNING,
createdAt,
updatedAt: createdAt,
output: [],
bufferedOutput: '',
forced: input.forced ?? true,
};
this.operations.set(id, operation);
this.logger.log(
`Starting plugin installation for "${input.name ?? input.url}" (operation ${id})`
);
this.publishEvent(operation, []);
const args = this.buildPluginArgs(operation);
const child = execa('plugin', args, {
all: true,
reject: false,
timeout: 5 * 60 * 1000,
});
operation.child = child;
if (child.all) {
child.all.on('data', (chunk) => {
this.handleOutput(operation, chunk.toString());
});
} else {
child.stdout?.on('data', (chunk) => this.handleOutput(operation, chunk.toString()));
child.stderr?.on('data', (chunk) => this.handleOutput(operation, chunk.toString()));
}
child.on('error', (error) => {
this.handleFailure(operation, error);
});
child.on('close', (code) => {
if (code === 0) {
this.handleSuccess(operation);
} else {
this.handleFailure(operation, new Error(`Plugin command exited with ${code}`));
}
});
return this.toGraphqlOperation(operation);
}
getOperation(id: string): PluginInstallOperation | null {
const operation = this.operations.get(id);
if (!operation) {
return null;
}
return this.toGraphqlOperation(operation);
}
listOperations(): PluginInstallOperation[] {
return Array.from(this.operations.values()).map((operation) =>
this.toGraphqlOperation(operation)
);
}
subscribe(operationId: string): PluginInstallSubscriberIterator {
if (!this.operations.has(operationId)) {
throw new Error(`Unknown plugin installation operation: ${operationId}`);
}
return createSubscription<{
pluginInstallUpdates: PluginInstallEvent;
}>(this.getChannel(operationId));
}
private buildPluginArgs(operation: OperationState): string[] {
const args = ['install', operation.url];
if (operation.forced) {
args.push('forced');
}
return args;
}
private handleOutput(operation: OperationState, chunk: string) {
const timestamp = new Date();
operation.updatedAt = timestamp;
operation.bufferedOutput += chunk;
const lines = this.extractCompleteLines(operation);
if (!lines.length) {
return;
}
operation.output.push(...lines);
this.trimOutput(operation);
this.publishEvent(operation, lines);
}
private extractCompleteLines(operation: OperationState): string[] {
const lines = operation.bufferedOutput.split(/\r?\n/);
operation.bufferedOutput = lines.pop() ?? '';
return lines.map((line) => line.trimEnd()).filter((line) => line.length > 0);
}
private handleSuccess(operation: OperationState) {
const timestamp = new Date();
operation.status = PluginInstallStatus.SUCCEEDED;
operation.finishedAt = timestamp;
operation.updatedAt = timestamp;
const trailingOutput = this.flushBuffer(operation);
if (trailingOutput.length) {
operation.output.push(...trailingOutput);
}
this.trimOutput(operation);
this.publishEvent(operation, trailingOutput);
this.publishEvent(operation, [], true);
this.logger.log(
`Plugin installation for "${operation.name ?? operation.url}" completed successfully (operation ${operation.id})`
);
}
private handleFailure(operation: OperationState, error: unknown) {
const timestamp = new Date();
operation.status = PluginInstallStatus.FAILED;
operation.finishedAt = timestamp;
operation.updatedAt = timestamp;
const trailingOutput = this.flushBuffer(operation);
if (trailingOutput.length) {
operation.output.push(...trailingOutput);
}
const errorLine = this.normalizeError(error);
if (errorLine) {
operation.output.push(errorLine);
}
this.trimOutput(operation);
const outputLines = [...trailingOutput];
if (errorLine) {
outputLines.push(errorLine);
}
this.publishEvent(operation, outputLines);
this.publishEvent(operation, [], true);
this.logger.error(
`Plugin installation for "${operation.name ?? operation.url}" failed (operation ${operation.id})`,
error instanceof Error ? error.stack : undefined
);
}
private flushBuffer(operation: OperationState): string[] {
if (!operation.bufferedOutput) {
return [];
}
const buffered = operation.bufferedOutput.trim();
operation.bufferedOutput = '';
return buffered.length ? [buffered] : [];
}
private normalizeError(error: unknown): string | null {
const extracted = this.extractErrorOutput(error);
if (extracted) {
const trimmed = extracted.trim();
if (trimmed.length) {
return trimmed;
}
}
if (error && typeof error === 'object' && 'code' in error) {
const code = (error as { code?: unknown }).code;
if (code === 'ENOENT') {
return 'Plugin command not found on this system.';
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return null;
}
private extractErrorOutput(error: unknown): string {
if (!error || typeof error !== 'object') {
return '';
}
const candidate = error as ExecaError & { all?: unknown };
return (
this.coerceToString(candidate.all) ??
this.coerceToString(candidate.stderr) ??
this.coerceToString(candidate.stdout) ??
this.coerceToString(candidate.shortMessage) ??
this.coerceToString(candidate.message) ??
''
);
}
private coerceToString(value: unknown): string | null {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value;
}
if (value instanceof Uint8Array) {
return Buffer.from(value).toString('utf-8');
}
if (Array.isArray(value)) {
const combined = value
.map((entry) => this.coerceToString(entry) ?? '')
.filter((entry) => entry.length > 0)
.join('\n');
return combined.length ? combined : null;
}
return null;
}
private trimOutput(operation: OperationState) {
if (operation.output.length <= this.MAX_OUTPUT_LINES) {
return;
}
const excess = operation.output.length - this.MAX_OUTPUT_LINES;
operation.output.splice(0, excess);
}
private publishEvent(operation: OperationState, output: string[], final = false) {
const event: PluginInstallEvent = {
operationId: operation.id,
status: operation.status,
output: output.length ? output : undefined,
timestamp: new Date(),
};
void pubsub.publish(this.getChannel(operation.id), {
pluginInstallUpdates: event,
});
if (final) {
// no-op placeholder for future cleanup hooks
}
}
private toGraphqlOperation(operation: OperationState): PluginInstallOperation {
return {
id: operation.id,
url: operation.url,
name: operation.name,
status: operation.status,
createdAt: operation.createdAt,
updatedAt: operation.updatedAt ?? null,
finishedAt: operation.finishedAt ?? null,
output: [...operation.output],
};
}
private getChannel(operationId: string): string {
return `${CHANNEL_PREFIX}${operationId}`;
}
}

View File

@@ -0,0 +1,97 @@
import { flushPromises, mount } from '@vue/test-utils';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import ActivationPluginsStep from '~/components/Activation/ActivationPluginsStep.vue';
import { PluginInstallStatus } from '~/composables/gql/graphql';
import { createTestI18n } from '../../utils/i18n';
const installPluginMock = vi.fn();
vi.mock('@unraid/ui', () => ({
BrandButton: {
props: ['text', 'variant', 'disabled', 'loading'],
template:
'<button data-testid="brand-button" :disabled="disabled" @click="$emit(\'click\')">{{ text }}</button>',
},
}));
vi.mock('~/components/Activation/usePluginInstaller', () => ({
default: () => ({
installPlugin: installPluginMock,
}),
}));
describe('ActivationPluginsStep', () => {
beforeEach(() => {
installPluginMock.mockReset();
});
const mountComponent = (overrides: Record<string, unknown> = {}) => {
const props = {
onComplete: vi.fn(),
onBack: vi.fn(),
onSkip: vi.fn(),
showBack: true,
showSkip: true,
...overrides,
};
return {
wrapper: mount(ActivationPluginsStep, {
props,
global: {
plugins: [createTestI18n()],
},
}),
props,
};
};
it('installs selected plugins, streams output, and completes', async () => {
installPluginMock.mockImplementation(async ({ onEvent }) => {
onEvent?.({
operationId: 'op-123',
status: PluginInstallStatus.RUNNING,
output: ['installation started'],
timestamp: new Date().toISOString(),
});
return {
operationId: 'op-123',
status: PluginInstallStatus.SUCCEEDED,
output: ['installation complete'],
};
});
const { wrapper, props } = mountComponent();
const installButton = wrapper
.findAll('[data-testid="brand-button"]')
.find((button) => button.text().includes('Install'));
expect(installButton).toBeTruthy();
await installButton!.trigger('click');
await flushPromises();
expect(installPluginMock).toHaveBeenCalled();
const firstCallArgs = installPluginMock.mock.calls[0]?.[0];
expect(firstCallArgs?.forced).toBe(true);
expect(firstCallArgs?.url).toContain('community.applications');
expect(props.onComplete).toHaveBeenCalled();
expect(wrapper.html()).toContain('installation started');
expect(wrapper.html()).toContain('installed successfully');
});
it('shows error message when installation fails', async () => {
installPluginMock.mockRejectedValueOnce(new Error('install failed'));
const { wrapper, props } = mountComponent();
const installButton = wrapper
.findAll('[data-testid="brand-button"]')
.find((button) => button.text().includes('Install'));
await installButton!.trigger('click');
await flushPromises();
expect(props.onComplete).not.toHaveBeenCalled();
expect(wrapper.html()).toContain('Failed to install plugins. Please try again.');
});
});

View File

@@ -3,7 +3,9 @@ import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { BrandButton } from '@unraid/ui';
import useInstallPlugin from '@/composables/installPlugin';
import usePluginInstaller from '~/components/Activation/usePluginInstaller';
import { PluginInstallStatus } from '~/composables/gql/graphql';
export interface Props {
onComplete: () => void;
@@ -47,8 +49,17 @@ const availablePlugins: Plugin[] = [
const selectedPlugins = ref<Set<string>>(new Set(availablePlugins.map((p) => p.id)));
const isInstalling = ref(false);
const error = ref<string | null>(null);
const installationLogs = ref<string[]>([]);
const { install } = useInstallPlugin();
const { installPlugin } = usePluginInstaller();
const appendLogs = (lines: string[] | string) => {
if (Array.isArray(lines)) {
lines.forEach((line) => installationLogs.value.push(line));
} else {
installationLogs.value.push(lines);
}
};
const togglePlugin = (pluginId: string) => {
const next = new Set(selectedPlugins.value);
@@ -68,16 +79,30 @@ const handleInstall = async () => {
isInstalling.value = true;
error.value = null;
installationLogs.value = [];
try {
const pluginsToInstall = availablePlugins.filter((p) => selectedPlugins.value.has(p.id));
for (const plugin of pluginsToInstall) {
install({
pluginUrl: plugin.url,
modalTitle: `Installing ${plugin.name}`,
appendLogs(t('activation.pluginsStep.installingPluginMessage', { name: plugin.name }));
const result = await installPlugin({
url: plugin.url,
name: plugin.name,
forced: true,
onEvent: (event) => {
if (event.output?.length) {
appendLogs(event.output.map((line) => `[${plugin.name}] ${line}`));
}
},
});
await new Promise((resolve) => setTimeout(resolve, 1000));
if (result.status !== PluginInstallStatus.SUCCEEDED) {
throw new Error(`Plugin installation failed for ${plugin.name}`);
}
appendLogs(t('activation.pluginsStep.pluginInstalledMessage', { name: plugin.name }));
}
props.onComplete();
@@ -118,6 +143,7 @@ const handleBack = () => {
:id="plugin.id"
type="checkbox"
:checked="selectedPlugins.has(plugin.id)"
:disabled="isInstalling"
@change="() => togglePlugin(plugin.id)"
class="text-primary focus:ring-primary mt-1 h-5 w-5 cursor-pointer rounded border-gray-300 focus:ring-2"
/>
@@ -128,6 +154,15 @@ const handleBack = () => {
</label>
</div>
<div
v-if="installationLogs.length > 0"
class="border-border bg-muted/40 mb-4 max-h-48 w-full overflow-y-auto rounded border p-3 text-left font-mono text-xs"
>
<div v-for="(line, index) in installationLogs" :key="`${index}-${line}`">
{{ line }}
</div>
</div>
<div v-if="error" class="mb-4 text-sm text-red-500">
{{ error }}
</div>

View File

@@ -0,0 +1,18 @@
import { graphql } from '~/composables/gql';
export const INSTALL_PLUGIN_MUTATION = graphql(/* GraphQL */ `
mutation InstallPlugin($input: InstallPluginInput!) {
unraidPlugins {
installPlugin(input: $input) {
id
url
name
status
createdAt
updatedAt
finishedAt
output
}
}
}
`);

View File

@@ -0,0 +1,16 @@
import { graphql } from '~/composables/gql';
export const PLUGIN_INSTALL_OPERATION_QUERY = graphql(/* GraphQL */ `
query PluginInstallOperation($operationId: ID!) {
pluginInstallOperation(operationId: $operationId) {
id
url
name
status
createdAt
updatedAt
finishedAt
output
}
}
`);

View File

@@ -0,0 +1,12 @@
import { graphql } from '~/composables/gql';
export const PLUGIN_INSTALL_UPDATES_SUBSCRIPTION = graphql(/* GraphQL */ `
subscription PluginInstallUpdates($operationId: ID!) {
pluginInstallUpdates(operationId: $operationId) {
operationId
status
output
timestamp
}
}
`);

View File

@@ -0,0 +1,110 @@
import { useApolloClient } from '@vue/apollo-composable';
import type { PluginInstallEvent } from '~/composables/gql/graphql';
import { INSTALL_PLUGIN_MUTATION } from '~/components/Activation/graphql/installPlugin.mutation';
import { PLUGIN_INSTALL_OPERATION_QUERY } from '~/components/Activation/graphql/pluginInstallOperation.query';
import { PLUGIN_INSTALL_UPDATES_SUBSCRIPTION } from '~/components/Activation/graphql/pluginInstallUpdates.subscription';
import { PluginInstallStatus } from '~/composables/gql/graphql';
export interface InstallPluginOptions {
url: string;
name?: string;
forced?: boolean;
onEvent?: (event: PluginInstallEvent) => void;
}
export interface InstallPluginResult {
operationId: string;
status: PluginInstallStatus;
output: string[];
}
const isFinalStatus = (status: PluginInstallStatus) =>
status === PluginInstallStatus.SUCCEEDED || status === PluginInstallStatus.FAILED;
const usePluginInstaller = () => {
const apolloClient = useApolloClient().client;
const installPlugin = async ({
url,
name,
forced = true,
onEvent,
}: InstallPluginOptions): Promise<InstallPluginResult> => {
const { data } = await apolloClient.mutate({
mutation: INSTALL_PLUGIN_MUTATION,
variables: { input: { url, name, forced } },
fetchPolicy: 'no-cache',
});
const operation = data?.unraidPlugins?.installPlugin;
if (!operation) {
throw new Error('Failed to start plugin installation');
}
const trackedOutput = [...(operation.output ?? [])];
if (isFinalStatus(operation.status)) {
return {
operationId: operation.id,
status: operation.status,
output: trackedOutput,
};
}
return new Promise<InstallPluginResult>((resolve, reject) => {
const observable = apolloClient.subscribe({
query: PLUGIN_INSTALL_UPDATES_SUBSCRIPTION,
variables: { operationId: operation.id },
});
const subscription = observable.subscribe({
next: ({ data: subscriptionData }) => {
const event = subscriptionData?.pluginInstallUpdates;
if (!event) {
return;
}
if (event.output?.length) {
trackedOutput.push(...event.output);
}
onEvent?.(event);
if (isFinalStatus(event.status)) {
void apolloClient
.query({
query: PLUGIN_INSTALL_OPERATION_QUERY,
variables: { operationId: operation.id },
fetchPolicy: 'network-only',
})
.then((result) => {
const operationResult = result.data?.pluginInstallOperation;
subscription.unsubscribe();
resolve({
operationId: operation.id,
status: event.status,
output: operationResult?.output ?? trackedOutput,
});
})
.catch((error) => {
subscription.unsubscribe();
reject(error);
});
}
},
error: (error) => {
subscription.unsubscribe();
reject(error);
},
});
});
};
return {
installPlugin,
};
};
export default usePluginInstaller;

View File

@@ -19,6 +19,9 @@ type Documents = {
"\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": typeof types.PartnerInfoDocument,
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": typeof types.PublicWelcomeDataDocument,
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": typeof types.ActivationCodeDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": typeof types.InstallPluginDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": typeof types.PluginInstallOperationDocument,
"\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 UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": typeof types.UpdateSystemTimeDocument,
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": typeof types.GetApiKeyCreationFormSchemaDocument,
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": typeof types.CreateApiKeyDocument,
@@ -92,6 +95,9 @@ const documents: Documents = {
"\n query PartnerInfo {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n": types.PartnerInfoDocument,
"\n query PublicWelcomeData {\n publicPartnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n isInitialSetup\n }\n": types.PublicWelcomeDataDocument,
"\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n": types.ActivationCodeDocument,
"\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n": types.InstallPluginDocument,
"\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n": types.PluginInstallOperationDocument,
"\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n": types.PluginInstallUpdatesDocument,
"\n mutation UpdateSystemTime($input: UpdateSystemTimeInput!) {\n updateSystemTime(input: $input) {\n currentTime\n timeZone\n useNtp\n ntpServers\n }\n }\n": types.UpdateSystemTimeDocument,
"\n query GetApiKeyCreationFormSchema {\n getApiKeyCreationFormSchema {\n id\n dataSchema\n uiSchema\n values\n }\n }\n": types.GetApiKeyCreationFormSchemaDocument,
"\n mutation CreateApiKey($input: CreateApiKeyInput!) {\n apiKey {\n create(input: $input) {\n ...ApiKey\n }\n }\n }\n": types.CreateApiKeyDocument,
@@ -194,6 +200,18 @@ export function graphql(source: "\n query PublicWelcomeData {\n publicPartne
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\n }\n }\n }\n"): (typeof documents)["\n query ActivationCode {\n vars {\n regState\n }\n customization {\n activationCode {\n code\n partnerName\n serverName\n sysModel\n comment\n header\n headermetacolor\n background\n showBannerGradient\n theme\n }\n partnerInfo {\n hasPartnerLogo\n partnerName\n partnerUrl\n partnerLogoUrl\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 InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n }\n"): (typeof documents)["\n mutation InstallPlugin($input: InstallPluginInput!) {\n unraidPlugins {\n installPlugin(input: $input) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\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 query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\n }\n }\n"): (typeof documents)["\n query PluginInstallOperation($operationId: ID!) {\n pluginInstallOperation(operationId: $operationId) {\n id\n url\n name\n status\n createdAt\n updatedAt\n finishedAt\n output\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 subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n"): (typeof documents)["\n subscription PluginInstallUpdates($operationId: ID!) {\n pluginInstallUpdates(operationId: $operationId) {\n operationId\n status\n output\n timestamp\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -1363,6 +1363,16 @@ export type InitiateFlashBackupInput = {
sourcePath: Scalars['String']['input'];
};
/** Input payload for installing a plugin */
export type InstallPluginInput = {
/** Force installation even when plugin is already present. Defaults to true to mirror the existing UI behaviour. */
forced?: InputMaybe<Scalars['Boolean']['input']>;
/** Optional human-readable plugin name used for logging */
name?: InputMaybe<Scalars['String']['input']>;
/** Plugin installation URL (.plg) */
url: Scalars['String']['input'];
};
export type KeyFile = {
__typename?: 'KeyFile';
contents?: Maybe<Scalars['String']['output']>;
@@ -1519,6 +1529,7 @@ export type Mutation = {
syncDockerTemplatePaths: DockerTemplateSyncResult;
unarchiveAll: NotificationOverview;
unarchiveNotifications: NotificationOverview;
unraidPlugins: UnraidPluginsMutations;
/** Marks a notification as unread. */
unreadNotification: Notification;
updateApiSettings: ConnectSettingsValues;
@@ -1923,6 +1934,48 @@ export type Plugin = {
version: Scalars['String']['output'];
};
/** Emitted event representing progress for a plugin installation */
export type PluginInstallEvent = {
__typename?: 'PluginInstallEvent';
/** Identifier of the related plugin installation operation */
operationId: Scalars['ID']['output'];
/** Output lines newly emitted since the previous event */
output?: Maybe<Array<Scalars['String']['output']>>;
/** Status reported with this event */
status: PluginInstallStatus;
/** Timestamp when the event was emitted */
timestamp: Scalars['DateTime']['output'];
};
/** Represents a tracked plugin installation operation */
export type PluginInstallOperation = {
__typename?: 'PluginInstallOperation';
/** Timestamp when the operation was created */
createdAt: Scalars['DateTime']['output'];
/** Timestamp when the operation finished, if applicable */
finishedAt?: Maybe<Scalars['DateTime']['output']>;
/** Unique identifier of the operation */
id: Scalars['ID']['output'];
/** Optional plugin name for display purposes */
name?: Maybe<Scalars['String']['output']>;
/** Collected output lines generated by the installer (capped at recent lines) */
output: Array<Scalars['String']['output']>;
/** Current status of the operation */
status: PluginInstallStatus;
/** Timestamp for the last update to this operation */
updatedAt?: Maybe<Scalars['DateTime']['output']>;
/** Plugin URL passed to the installer */
url: Scalars['String']['output'];
};
/** Status of a plugin installation operation */
export enum PluginInstallStatus {
FAILED = 'FAILED',
QUEUED = 'QUEUED',
RUNNING = 'RUNNING',
SUCCEEDED = 'SUCCEEDED'
}
export type PluginManagementInput = {
/** Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only. */
bundled?: Scalars['Boolean']['input'];
@@ -2004,6 +2057,10 @@ export type Query = {
online: Scalars['Boolean']['output'];
owner: Owner;
parityHistory: Array<ParityCheck>;
/** Retrieve a plugin installation operation by identifier */
pluginInstallOperation?: Maybe<PluginInstallOperation>;
/** List all tracked plugin installation operations */
pluginInstallOperations: Array<PluginInstallOperation>;
/** List all installed plugins with their metadata */
plugins: Array<Plugin>;
/** Preview the effective permissions for a combination of roles and explicit permissions */
@@ -2060,6 +2117,11 @@ export type QueryOidcProviderArgs = {
};
export type QueryPluginInstallOperationArgs = {
operationId: Scalars['ID']['input'];
};
export type QueryPreviewEffectivePermissionsArgs = {
permissions?: InputMaybe<Array<AddPermissionInput>>;
roles?: InputMaybe<Array<Role>>;
@@ -2361,6 +2423,7 @@ export type Subscription = {
notificationsWarningsAndAlerts: Array<Notification>;
ownerSubscription: Owner;
parityHistorySubscription: ParityCheck;
pluginInstallUpdates: PluginInstallEvent;
serversSubscription: Server;
systemMetricsCpu: CpuUtilization;
systemMetricsCpuTelemetry: CpuPackages;
@@ -2373,6 +2436,11 @@ export type SubscriptionLogFileArgs = {
path: Scalars['String']['input'];
};
export type SubscriptionPluginInstallUpdatesArgs = {
operationId: Scalars['ID']['input'];
};
/** System time configuration and current status */
export type SystemTime = {
__typename?: 'SystemTime';
@@ -2630,6 +2698,19 @@ export type UnraidArray = Node & {
state: ArrayState;
};
/** Unraid plugin management mutations */
export type UnraidPluginsMutations = {
__typename?: 'UnraidPluginsMutations';
/** Install an Unraid plugin and track installation progress */
installPlugin: PluginInstallOperation;
};
/** Unraid plugin management mutations */
export type UnraidPluginsMutationsInstallPluginArgs = {
input: InstallPluginInput;
};
export type UpdateApiKeyInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['PrefixedID']['input'];
@@ -3009,6 +3090,27 @@ export type ActivationCodeQueryVariables = Exact<{ [key: string]: never; }>;
export type ActivationCodeQuery = { __typename?: 'Query', vars: { __typename?: 'Vars', regState?: RegistrationState | null }, customization?: { __typename?: 'Customization', activationCode?: { __typename?: 'ActivationCode', code?: string | null, partnerName?: string | null, serverName?: string | null, sysModel?: string | null, comment?: string | null, header?: string | null, headermetacolor?: string | null, background?: string | null, showBannerGradient?: boolean | null, theme?: string | null } | null, partnerInfo?: { __typename?: 'PublicPartnerInfo', hasPartnerLogo: boolean, partnerName?: string | null, partnerUrl?: string | null, partnerLogoUrl?: string | null } | null } | null };
export type InstallPluginMutationVariables = Exact<{
input: InstallPluginInput;
}>;
export type InstallPluginMutation = { __typename?: 'Mutation', unraidPlugins: { __typename?: 'UnraidPluginsMutations', installPlugin: { __typename?: 'PluginInstallOperation', id: string, url: string, name?: string | null, status: PluginInstallStatus, createdAt: string, updatedAt?: string | null, finishedAt?: string | null, output: Array<string> } } };
export type PluginInstallOperationQueryVariables = Exact<{
operationId: Scalars['ID']['input'];
}>;
export type PluginInstallOperationQuery = { __typename?: 'Query', pluginInstallOperation?: { __typename?: 'PluginInstallOperation', id: string, url: string, name?: string | null, status: PluginInstallStatus, createdAt: string, updatedAt?: string | null, finishedAt?: string | null, output: Array<string> } | null };
export type PluginInstallUpdatesSubscriptionVariables = Exact<{
operationId: Scalars['ID']['input'];
}>;
export type PluginInstallUpdatesSubscription = { __typename?: 'Subscription', pluginInstallUpdates: { __typename?: 'PluginInstallEvent', operationId: string, status: PluginInstallStatus, output?: Array<string> | null, timestamp: string } };
export type UpdateSystemTimeMutationVariables = Exact<{
input: UpdateSystemTimeInput;
}>;
@@ -3466,6 +3568,9 @@ export const CompleteUpgradeStepDocument = {"kind":"Document","definitions":[{"k
export const PartnerInfoDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]} as unknown as DocumentNode<PartnerInfoQuery, PartnerInfoQueryVariables>;
export const PublicWelcomeDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PublicWelcomeData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publicPartnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"isInitialSetup"}}]}}]} as unknown as DocumentNode<PublicWelcomeDataQuery, PublicWelcomeDataQueryVariables>;
export const ActivationCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ActivationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"vars"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"regState"}}]}},{"kind":"Field","name":{"kind":"Name","value":"customization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"activationCode"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"code"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"serverName"}},{"kind":"Field","name":{"kind":"Name","value":"sysModel"}},{"kind":"Field","name":{"kind":"Name","value":"comment"}},{"kind":"Field","name":{"kind":"Name","value":"header"}},{"kind":"Field","name":{"kind":"Name","value":"headermetacolor"}},{"kind":"Field","name":{"kind":"Name","value":"background"}},{"kind":"Field","name":{"kind":"Name","value":"showBannerGradient"}},{"kind":"Field","name":{"kind":"Name","value":"theme"}}]}},{"kind":"Field","name":{"kind":"Name","value":"partnerInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasPartnerLogo"}},{"kind":"Field","name":{"kind":"Name","value":"partnerName"}},{"kind":"Field","name":{"kind":"Name","value":"partnerUrl"}},{"kind":"Field","name":{"kind":"Name","value":"partnerLogoUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ActivationCodeQuery, ActivationCodeQueryVariables>;
export const InstallPluginDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InstallPlugin"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InstallPluginInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unraidPlugins"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"installPlugin"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]}}]} as unknown as DocumentNode<InstallPluginMutation, InstallPluginMutationVariables>;
export const PluginInstallOperationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"PluginInstallOperation"},"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":"pluginInstallOperation"},"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":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"finishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"output"}}]}}]}}]} as unknown as DocumentNode<PluginInstallOperationQuery, PluginInstallOperationQueryVariables>;
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<PluginInstallUpdatesSubscription, PluginInstallUpdatesSubscriptionVariables>;
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<UpdateSystemTimeMutation, UpdateSystemTimeMutationVariables>;
export const GetApiKeyCreationFormSchemaDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getApiKeyCreationFormSchema"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"}}]}}]}}]} as unknown as DocumentNode<GetApiKeyCreationFormSchemaQuery, GetApiKeyCreationFormSchemaQueryVariables>;
export const CreateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"create"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKey"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKey"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"roles"}},{"kind":"Field","name":{"kind":"Name","value":"permissions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"resource"}},{"kind":"Field","name":{"kind":"Name","value":"actions"}}]}}]}}]} as unknown as DocumentNode<CreateApiKeyMutation, CreateApiKeyMutationVariables>;

View File

@@ -16,6 +16,8 @@
"activation.pluginsStep.installAndContinue": "Install & Continue",
"activation.pluginsStep.installEssentialPlugins": "Install Essential Plugins",
"activation.pluginsStep.installFailed": "Failed to install plugins. Please try again.",
"activation.pluginsStep.installingPluginMessage": "Installing {name}...",
"activation.pluginsStep.pluginInstalledMessage": "{name} installed successfully.",
"activation.pluginsStep.selectPluginsDescription": "Select the plugins you want to install. You can always add more later.",
"activation.timezoneStep.selectTimezoneDescription": "Select your time zone to ensure accurate timestamps throughout the system.",
"activation.timezoneStep.selectTimezoneError": "Please select a timezone",