From da381b5f95d096b852aa0ba18d546d49958c7240 Mon Sep 17 00:00:00 2001 From: Eli Bosley Date: Wed, 15 Oct 2025 17:23:02 -0400 Subject: [PATCH] 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. --- api/generated-schema.graphql | 142 ++++++-- api/src/unraid-api/cli/generated/graphql.ts | 81 +++++ .../resolvers/mutation/mutation.model.ts | 14 + .../resolvers/mutation/mutation.resolver.ts | 6 + .../graph/resolvers/resolvers.module.ts | 2 + .../unraid-plugins/unraid-plugins.model.ts | 114 +++++++ .../unraid-plugins/unraid-plugins.module.ts | 11 + .../unraid-plugins/unraid-plugins.mutation.ts | 27 ++ .../unraid-plugins/unraid-plugins.resolver.ts | 54 +++ .../unraid-plugins.service.spec.ts | 112 +++++++ .../unraid-plugins/unraid-plugins.service.ts | 313 ++++++++++++++++++ .../Activation/ActivationPluginsStep.test.ts | 97 ++++++ .../Activation/ActivationPluginsStep.vue | 47 ++- .../graphql/installPlugin.mutation.ts | 18 + .../graphql/pluginInstallOperation.query.ts | 16 + .../pluginInstallUpdates.subscription.ts | 12 + .../Activation/usePluginInstaller.ts | 110 ++++++ web/src/composables/gql/gql.ts | 18 + web/src/composables/gql/graphql.ts | 105 ++++++ web/src/locales/en.json | 2 + 20 files changed, 1264 insertions(+), 37 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts create mode 100644 api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts create mode 100644 web/__test__/components/Activation/ActivationPluginsStep.test.ts create mode 100644 web/src/components/Activation/graphql/installPlugin.mutation.ts create mode 100644 web/src/components/Activation/graphql/pluginInstallOperation.query.ts create mode 100644 web/src/components/Activation/graphql/pluginInstallUpdates.subscription.ts create mode 100644 web/src/components/Activation/usePluginInstaller.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 05bf74a79..9f8637f78 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -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! } \ 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 0e033b689..99118b359 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -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; + /** Optional human-readable plugin name used for logging */ + name?: InputMaybe; + /** Plugin installation URL (.plg) */ + url: Scalars['String']['input']; +}; + export type KeyFile = { __typename?: 'KeyFile'; contents?: Maybe; @@ -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>; + /** 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; + /** Unique identifier of the operation */ + id: Scalars['ID']['output']; + /** Optional plugin name for display purposes */ + name?: Maybe; + /** Collected output lines generated by the installer (capped at recent lines) */ + output: Array; + /** Current status of the operation */ + status: PluginInstallStatus; + /** Timestamp for the last update to this operation */ + updatedAt?: Maybe; + /** 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; + /** Retrieve a plugin installation operation by identifier */ + pluginInstallOperation?: Maybe; + /** List all tracked plugin installation operations */ + pluginInstallOperations: Array; /** List all installed plugins with their metadata */ plugins: Array; /** 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>; roles?: InputMaybe>; @@ -2361,6 +2423,7 @@ export type Subscription = { notificationsWarningsAndAlerts: Array; 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; id: Scalars['PrefixedID']['input']; 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 840343780..237072b43 100644 --- a/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts +++ b/api/src/unraid-api/graph/resolvers/mutation/mutation.model.ts @@ -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(); } 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 a32e83dd9..5ab6b2ad8 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, + 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(); + } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 84a62616f..664757b72 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -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, diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts new file mode 100644 index 000000000..2c264d464 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.model.ts @@ -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; +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts new file mode 100644 index 000000000..5f98fc59a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.module.ts @@ -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 {} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts new file mode 100644 index 000000000..197b342f5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.mutation.ts @@ -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 { + return this.pluginsService.installPlugin(input); + } +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts new file mode 100644 index 000000000..c94de5cc5 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.resolver.ts @@ -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 { + 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 { + 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); + } +} diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts new file mode 100644 index 000000000..e0c2c8150 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.spec.ts @@ -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, + }), + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts new file mode 100644 index 000000000..bc2448465 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/unraid-plugins/unraid-plugins.service.ts @@ -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; + +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(); + private readonly MAX_OUTPUT_LINES = 500; + + async installPlugin(input: InstallPluginInput): Promise { + 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}`; + } +} diff --git a/web/__test__/components/Activation/ActivationPluginsStep.test.ts b/web/__test__/components/Activation/ActivationPluginsStep.test.ts new file mode 100644 index 000000000..c3d42dc07 --- /dev/null +++ b/web/__test__/components/Activation/ActivationPluginsStep.test.ts @@ -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: + '', + }, +})); + +vi.mock('~/components/Activation/usePluginInstaller', () => ({ + default: () => ({ + installPlugin: installPluginMock, + }), +})); + +describe('ActivationPluginsStep', () => { + beforeEach(() => { + installPluginMock.mockReset(); + }); + + const mountComponent = (overrides: Record = {}) => { + 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.'); + }); +}); diff --git a/web/src/components/Activation/ActivationPluginsStep.vue b/web/src/components/Activation/ActivationPluginsStep.vue index 9935f70b1..56a6362af 100644 --- a/web/src/components/Activation/ActivationPluginsStep.vue +++ b/web/src/components/Activation/ActivationPluginsStep.vue @@ -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>(new Set(availablePlugins.map((p) => p.id))); const isInstalling = ref(false); const error = ref(null); +const installationLogs = ref([]); -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 = () => { +
+
+ {{ line }} +
+
+
{{ error }}
diff --git a/web/src/components/Activation/graphql/installPlugin.mutation.ts b/web/src/components/Activation/graphql/installPlugin.mutation.ts new file mode 100644 index 000000000..a698a8f9e --- /dev/null +++ b/web/src/components/Activation/graphql/installPlugin.mutation.ts @@ -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 + } + } + } +`); diff --git a/web/src/components/Activation/graphql/pluginInstallOperation.query.ts b/web/src/components/Activation/graphql/pluginInstallOperation.query.ts new file mode 100644 index 000000000..c03cfa0b6 --- /dev/null +++ b/web/src/components/Activation/graphql/pluginInstallOperation.query.ts @@ -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 + } + } +`); diff --git a/web/src/components/Activation/graphql/pluginInstallUpdates.subscription.ts b/web/src/components/Activation/graphql/pluginInstallUpdates.subscription.ts new file mode 100644 index 000000000..0da8a1919 --- /dev/null +++ b/web/src/components/Activation/graphql/pluginInstallUpdates.subscription.ts @@ -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 + } + } +`); diff --git a/web/src/components/Activation/usePluginInstaller.ts b/web/src/components/Activation/usePluginInstaller.ts new file mode 100644 index 000000000..0255af3fd --- /dev/null +++ b/web/src/components/Activation/usePluginInstaller.ts @@ -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 => { + 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((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; diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts index 67b6b110d..d750df99d 100644 --- a/web/src/composables/gql/gql.ts +++ b/web/src/composables/gql/gql.ts @@ -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. */ diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index fbda1992c..a1f5e8623 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -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; + /** Optional human-readable plugin name used for logging */ + name?: InputMaybe; + /** Plugin installation URL (.plg) */ + url: Scalars['String']['input']; +}; + export type KeyFile = { __typename?: 'KeyFile'; contents?: Maybe; @@ -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>; + /** 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; + /** Unique identifier of the operation */ + id: Scalars['ID']['output']; + /** Optional plugin name for display purposes */ + name?: Maybe; + /** Collected output lines generated by the installer (capped at recent lines) */ + output: Array; + /** Current status of the operation */ + status: PluginInstallStatus; + /** Timestamp for the last update to this operation */ + updatedAt?: Maybe; + /** 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; + /** Retrieve a plugin installation operation by identifier */ + pluginInstallOperation?: Maybe; + /** List all tracked plugin installation operations */ + pluginInstallOperations: Array; /** List all installed plugins with their metadata */ plugins: Array; /** 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>; roles?: InputMaybe>; @@ -2361,6 +2423,7 @@ export type Subscription = { notificationsWarningsAndAlerts: Array; 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; 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 } } }; + +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 } | null }; + +export type PluginInstallUpdatesSubscriptionVariables = Exact<{ + operationId: Scalars['ID']['input']; +}>; + + +export type PluginInstallUpdatesSubscription = { __typename?: 'Subscription', pluginInstallUpdates: { __typename?: 'PluginInstallEvent', operationId: string, status: PluginInstallStatus, output?: Array | 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; 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; 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; +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; +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; +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 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 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; 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; diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 4a65e1c36..610cb6a6f 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -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",