mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
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:
@@ -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 {
|
||||
|
||||
36
api/src/unraid-api/plugin/plugin.model.ts
Normal file
36
api/src/unraid-api/plugin/plugin.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
96
api/src/unraid-api/plugin/plugin.resolver.ts
Normal file
96
api/src/unraid-api/plugin/plugin.resolver.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user