feat: add graphql resource for API plugins (#1420)

Addresses #1350 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced a plugin management system, allowing users to view, add,
and remove plugins through the API.
- Added the ability to control plugin installation options, including
bundled installation and API restart behavior.

- **Removals**
- Removed all remote access, network, and cloud-related features and
settings from the API.

- **Improvements**
- Enhanced API queries and mutations to focus on plugin management and
metadata.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Pujit Mehrotra
2025-06-16 14:18:13 -04:00
committed by GitHub
parent b6c4ee6eb4
commit 642a220c3a
5 changed files with 178 additions and 227 deletions

View File

@@ -226,27 +226,6 @@ type Share implements Node {
luksStatus: String
}
type AccessUrl {
type: URL_TYPE!
name: String
ipv4: URL
ipv6: URL
}
enum URL_TYPE {
LAN
WIREGUARD
WAN
MDNS
OTHER
DEFAULT
}
"""
A field whose value conforms to the standard URL format as specified in RFC3986: https://www.ietf.org/rfc/rfc3986.txt.
"""
scalar URL
type DiskPartition {
"""The name of the partition"""
name: String!
@@ -1371,6 +1350,7 @@ type ApiConfig {
extraOrigins: [String!]!
sandbox: Boolean
ssoSubIds: [String!]!
plugins: [String!]!
}
type UnifiedSettings implements Node {
@@ -1464,137 +1444,18 @@ type UserAccount implements Node {
permissions: [Permission!]
}
type AccessUrlObject {
ipv4: String
ipv6: String
type: URL_TYPE!
name: String
}
type Plugin {
"""The name of the plugin package"""
name: String!
type RemoteAccess {
"""The type of WAN access used for Remote Access"""
accessType: WAN_ACCESS_TYPE!
"""The version of the plugin package"""
version: String!
"""The type of port forwarding used for Remote Access"""
forwardType: WAN_FORWARD_TYPE
"""Whether the plugin has an API module"""
hasApiModule: Boolean
"""The port used for Remote Access"""
port: Int
}
enum WAN_ACCESS_TYPE {
DYNAMIC
ALWAYS
DISABLED
}
enum WAN_FORWARD_TYPE {
UPNP
STATIC
}
type DynamicRemoteAccessStatus {
"""The type of dynamic remote access that is enabled"""
enabledType: DynamicRemoteAccessType!
"""The type of dynamic remote access that is currently running"""
runningType: DynamicRemoteAccessType!
"""Any error message associated with the dynamic remote access"""
error: String
}
enum DynamicRemoteAccessType {
STATIC
UPNP
DISABLED
}
type ConnectSettingsValues {
"""The type of WAN access used for Remote Access"""
accessType: WAN_ACCESS_TYPE!
"""The type of port forwarding used for Remote Access"""
forwardType: WAN_FORWARD_TYPE
"""The port used for Remote Access"""
port: Int
}
type ConnectSettings implements Node {
id: PrefixedID!
"""The data schema for the Connect settings"""
dataSchema: JSON!
"""The UI schema for the Connect settings"""
uiSchema: JSON!
"""The values for the Connect settings"""
values: ConnectSettingsValues!
}
type Connect implements Node {
id: PrefixedID!
"""The status of dynamic remote access"""
dynamicRemoteAccess: DynamicRemoteAccessStatus!
"""The settings for the Connect instance"""
settings: ConnectSettings!
}
type Network implements Node {
id: PrefixedID!
accessUrls: [AccessUrl!]
}
type ApiKeyResponse {
valid: Boolean!
error: String
}
type MinigraphqlResponse {
status: MinigraphStatus!
timeout: Int
error: String
}
"""The status of the minigraph"""
enum MinigraphStatus {
PRE_INIT
CONNECTING
CONNECTED
PING_FAILURE
ERROR_RETRYING
}
type CloudResponse {
status: String!
ip: String
error: String
}
type RelayResponse {
status: String!
timeout: String
error: String
}
type Cloud {
error: String
apiKey: ApiKeyResponse!
relay: RelayResponse
minigraphql: MinigraphqlResponse!
cloud: CloudResponse!
allowedOrigins: [String!]!
}
input AccessUrlObjectInput {
ipv4: String
ipv6: String
type: URL_TYPE!
name: String
"""Whether the plugin has a CLI module"""
hasCliModule: Boolean
}
"\n### Description:\n\nID scalar type that prefixes the underlying ID with the server identifier on output and strips it on input.\n\nWe use this scalar type to ensure that the ID is unique across all servers, allowing the same underlying resource ID to be used across different server instances.\n\n#### Input Behavior:\n\nWhen providing an ID as input (e.g., in arguments or input objects), the server identifier prefix ('<serverId>:') is optional.\n\n- If the prefix is present (e.g., '123:456'), it will be automatically stripped, and only the underlying ID ('456') will be used internally.\n- If the prefix is absent (e.g., '456'), the ID will be used as-is.\n\nThis makes it flexible for clients, as they don't strictly need to know or provide the server ID.\n\n#### Output Behavior:\n\nWhen an ID is returned in the response (output), it will *always* be prefixed with the current server's unique identifier (e.g., '123:456').\n\n#### Example:\n\nNote: The server identifier is '123' in this example.\n\n##### Input (Prefix Optional):\n```graphql\n# Both of these are valid inputs resolving to internal ID '456'\n{\n someQuery(id: \"123:456\") { ... }\n anotherQuery(id: \"456\") { ... }\n}\n```\n\n##### Output (Prefix Always Added):\n```graphql\n# Assuming internal ID is '456'\n{\n \"data\": {\n \"someResource\": {\n \"id\": \"123:456\" \n }\n }\n}\n```\n "
@@ -1640,10 +1501,9 @@ type Query {
disk(id: PrefixedID!): Disk!
rclone: RCloneBackupSettings!
settings: Settings!
remoteAccess: RemoteAccess!
connect: Connect!
network: Network!
cloud: Cloud!
"""List all installed plugins with their metadata"""
plugins: [Plugin!]!
}
type Mutation {
@@ -1676,11 +1536,16 @@ type Mutation {
"""Initiates a flash drive backup using a configured remote."""
initiateFlashBackup(input: InitiateFlashBackupInput!): FlashBackupStatus!
updateSettings(input: JSON!): UpdateSettingsResponse!
updateApiSettings(input: ConnectSettingsInput!): ConnectSettingsValues!
connectSignIn(input: ConnectSignInInput!): Boolean!
connectSignOut: Boolean!
setupRemoteAccess(input: SetupRemoteAccessInput!): Boolean!
enableDynamicRemoteAccess(input: EnableDynamicRemoteAccessInput!): Boolean!
"""
Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required.
"""
addPlugin(input: PluginManagementInput!): Boolean!
"""
Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.
"""
removePlugin(input: PluginManagementInput!): Boolean!
}
input NotificationData {
@@ -1707,73 +1572,19 @@ input InitiateFlashBackupInput {
options: JSON
}
input ConnectSettingsInput {
"""The type of WAN access to use for Remote Access"""
accessType: WAN_ACCESS_TYPE
"""The type of port forwarding to use for Remote Access"""
forwardType: WAN_FORWARD_TYPE
input PluginManagementInput {
"""Array of plugin package names to add or remove"""
names: [String!]!
"""
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.
Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only.
"""
port: Int
}
input ConnectSignInInput {
"""The API key for authentication"""
apiKey: String!
"""The ID token for authentication"""
idToken: String
"""User information for the sign-in"""
userInfo: ConnectUserInfoInput
"""The access token for authentication"""
accessToken: String
"""The refresh token for authentication"""
refreshToken: String
}
input ConnectUserInfoInput {
"""The preferred username of the user"""
preferred_username: String!
"""The email address of the user"""
email: String!
"""The avatar URL of the user"""
avatar: String
}
input SetupRemoteAccessInput {
"""The type of WAN access to use for Remote Access"""
accessType: WAN_ACCESS_TYPE!
"""The type of port forwarding to use for Remote Access"""
forwardType: WAN_FORWARD_TYPE
bundled: Boolean! = false
"""
The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType. Ignored if accessType is DISABLED or forwardType is UPNP.
Whether to restart the API after the operation. When false, a restart has already been queued.
"""
port: Int
}
input EnableDynamicRemoteAccessInput {
"""The AccessURL Input for dynamic remote access"""
url: AccessUrlInput!
"""Whether to enable or disable dynamic remote access"""
enabled: Boolean!
}
input AccessUrlInput {
type: URL_TYPE!
name: String
ipv4: URL
ipv6: URL
restart: Boolean! = true
}
type Subscription {

View File

@@ -0,0 +1,36 @@
import { Field, InputType, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class Plugin {
@Field({ description: 'The name of the plugin package' })
name!: string;
@Field({ description: 'The version of the plugin package' })
version!: string;
@Field({ nullable: true, description: 'Whether the plugin has an API module' })
hasApiModule?: boolean;
@Field({ nullable: true, description: 'Whether the plugin has a CLI module' })
hasCliModule?: boolean;
}
@InputType()
export class PluginManagementInput {
@Field(() => [String], { description: 'Array of plugin package names to add or remove' })
names!: string[];
@Field({
defaultValue: false,
description:
'Whether to treat plugins as bundled plugins. Bundled plugins are installed to node_modules at build time and controlled via config only.',
})
bundled!: boolean;
@Field({
defaultValue: true,
description:
'Whether to restart the API after the operation. When false, a restart has already been queued.',
})
restart!: boolean;
}

View File

@@ -4,6 +4,7 @@ import { DependencyService } from '@app/unraid-api/app/dependency.service.js';
import { ResolversModule } from '@app/unraid-api/graph/resolvers/resolvers.module.js';
import { GlobalDepsModule } from '@app/unraid-api/plugin/global-deps.module.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
import { PluginResolver } from '@app/unraid-api/plugin/plugin.resolver.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Module({})
@@ -22,7 +23,7 @@ export class PluginModule {
return {
module: PluginModule,
imports: [GlobalDepsModule, ResolversModule, ...apiModules],
providers: [PluginService, PluginManagementService, DependencyService],
providers: [PluginService, PluginManagementService, DependencyService, PluginResolver],
exports: [PluginService, PluginManagementService, DependencyService, GlobalDepsModule],
};
}

View File

@@ -0,0 +1,96 @@
import { Injectable } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import {
AuthActionVerb,
AuthPossession,
UsePermissions,
} from '@unraid/shared/use-permissions.directive.js';
import { LifecycleService } from '@app/unraid-api/app/lifecycle.service.js';
import { PluginManagementService } from '@app/unraid-api/plugin/plugin-management.service.js';
import { Plugin, PluginManagementInput } from '@app/unraid-api/plugin/plugin.model.js';
import { PluginService } from '@app/unraid-api/plugin/plugin.service.js';
@Injectable()
@Resolver(() => Plugin)
export class PluginResolver {
constructor(
private readonly pluginManagementService: PluginManagementService,
private readonly lifecycleService: LifecycleService
) {}
@Query(() => [Plugin], { description: 'List all installed plugins with their metadata' })
@UsePermissions({
action: AuthActionVerb.READ,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async plugins(): Promise<Plugin[]> {
const plugins = await PluginService.getPlugins();
return plugins.map((p) => ({
name: p.name,
version: p.version,
hasApiModule: !!p.ApiModule,
hasCliModule: !!p.CliModule,
}));
}
/**
* Adds a plugin to the api.
* @param input
* @returns boolean indicating whether a separate restart is required. when false, the restart will be triggered automatically.
*/
@Mutation(() => Boolean, {
description:
'Add one or more plugins to the API. Returns false if restart was triggered automatically, true if manual restart is required.',
})
@UsePermissions({
action: AuthActionVerb.UPDATE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async addPlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
if (input.bundled) {
await this.pluginManagementService.addBundledPlugin(...input.names);
} else {
await this.pluginManagementService.addPlugin(...input.names);
}
if (input.restart) {
this.lifecycleService.restartApi({ delayMs: 300 });
return false;
}
return true;
}
/**
* Removes a plugin from the api.
* @param input
* @returns boolean indicating whether a separate restart is required. when false, the restart will be triggered automatically.
*/
@Mutation(() => Boolean, {
description:
'Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required.',
})
@UsePermissions({
action: AuthActionVerb.DELETE,
resource: Resource.CONFIG,
possession: AuthPossession.ANY,
})
async removePlugin(@Args('input') input: PluginManagementInput): Promise<boolean> {
if (input.bundled) {
await this.pluginManagementService.removeBundledPlugin(...input.names);
} else {
await this.pluginManagementService.removePlugin(...input.names);
}
if (input.restart) {
this.lifecycleService.restartApi({ delayMs: 300 });
return false;
}
return true;
}
}

View File

@@ -3,18 +3,20 @@ import { Injectable, Logger } from '@nestjs/common';
import type { ApiNestPluginDefinition } from '@app/unraid-api/plugin/plugin.interface.js';
import { getPackageJson } from '@app/environment.js';
import { loadApiConfig } from '@app/unraid-api/config/api-config.module.js';
import {
NotificationImportance,
NotificationType,
} from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
import { apiNestPluginSchema } from '@app/unraid-api/plugin/plugin.interface.js';
import { batchProcess, parsePackageArg } from '@app/utils.js';
type Plugin = ApiNestPluginDefinition & {
name: string;
version: string;
};
@Injectable()
export class PluginService {
private static readonly logger = new Logger(PluginService.name);
private static plugins: Promise<ApiNestPluginDefinition[]> | undefined;
private static plugins: Promise<Plugin[]> | undefined;
static async getPlugins() {
PluginService.plugins ??= PluginService.importPlugins();
@@ -26,10 +28,15 @@ export class PluginService {
return PluginService.plugins;
}
const pluginPackages = await PluginService.listPlugins();
const plugins = await batchProcess(pluginPackages, async ([pkgName]) => {
const plugins = await batchProcess(pluginPackages, async ([pkgName, version]) => {
try {
const plugin = await import(/* @vite-ignore */ pkgName);
return apiNestPluginSchema.parse(plugin);
const parsedPlugin = apiNestPluginSchema.parse(plugin);
return {
...parsedPlugin,
name: pkgName,
version,
};
} catch (error) {
PluginService.logger.error(`Plugin from ${pkgName} is invalid`, error);
const notificationService = new NotificationsService();