mirror of
https://github.com/unraid/api.git
synced 2026-01-07 00:59:48 -06:00
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:
@@ -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!
|
||||
}
|
||||
@@ -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'];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`);
|
||||
110
web/src/components/Activation/usePluginInstaller.ts
Normal file
110
web/src/components/Activation/usePluginInstaller.ts
Normal 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;
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user