mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
7 Commits
fix/releas
...
feat/mothe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e30e2c7d | ||
|
|
81cc9ff960 | ||
|
|
90ed837237 | ||
|
|
67944ebf26 | ||
|
|
fcf74d347b | ||
|
|
59595499b3 | ||
|
|
ff941602b6 |
@@ -19,12 +19,14 @@ PATHS_LOGS_FILE=./dev/log/graphql-api.log
|
||||
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
|
||||
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
|
||||
PATHS_LOCAL_SESSION_FILE=./dev/local-session
|
||||
PATHS_CONNECT_STATUS=./dev/states/connectStatus.json # Connect status file for development
|
||||
ENVIRONMENT="development"
|
||||
NODE_ENV="development"
|
||||
PORT="3001"
|
||||
PLAYGROUND=true
|
||||
INTROSPECTION=true
|
||||
MOTHERSHIP_GRAPHQL_LINK="http://authenticator:3000/graphql"
|
||||
MOTHERSHIP_GRAPHQL_LINK="wss://preview.mothership2.unraid.net"
|
||||
MOTHERSHIP_BASE_URL="https://preview.mothership2.unraid.net"
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
BYPASS_PERMISSION_CHECKS=false
|
||||
BYPASS_CORS_CHECKS=true
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.25.3",
|
||||
"version": "4.27.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"wanaccess": true,
|
||||
"wanport": 8443,
|
||||
"upnpEnabled": false,
|
||||
"apikey": "",
|
||||
"apikey": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
|
||||
"email": "test@example.com",
|
||||
"username": "zspearmint",
|
||||
|
||||
7
api/dev/states/connectStatus.json
Normal file
7
api/dev/states/connectStatus.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"connectionStatus": "PRE_INIT",
|
||||
"error": null,
|
||||
"lastPing": null,
|
||||
"allowedOrigins": "",
|
||||
"timestamp": 1764601989840
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { exit } from 'process';
|
||||
import type { PackageJson } from 'type-fest';
|
||||
import { $, cd } from 'zx';
|
||||
|
||||
import { getDeploymentVersion } from './get-deployment-version.js';
|
||||
import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js';
|
||||
|
||||
type ApiPackageJson = PackageJson & {
|
||||
version: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { applyPatch, createPatch, parsePatch, reversePatch } from 'diff';
|
||||
import { coerce, compare, gte, lte } from 'semver';
|
||||
|
||||
import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js';
|
||||
import { NODE_ENV } from '@app/environment.js';
|
||||
|
||||
export type ModificationEffect = 'nginx:reload';
|
||||
|
||||
@@ -225,6 +226,14 @@ export abstract class FileModification {
|
||||
throw new Error('Invalid file modification configuration');
|
||||
}
|
||||
|
||||
// Skip file modifications in development mode
|
||||
if (NODE_ENV === 'development') {
|
||||
return {
|
||||
shouldApply: false,
|
||||
reason: 'File modifications are disabled in development mode',
|
||||
};
|
||||
}
|
||||
|
||||
const fileExists = await access(this.filePath, constants.R_OK | constants.W_OK)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import type { ModificationEffect } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
import { NODE_ENV } from '@app/environment.js';
|
||||
import { FileModificationEffectService } from '@app/unraid-api/unraid-file-modifier/file-modification-effect.service.js';
|
||||
import { FileModification } from '@app/unraid-api/unraid-file-modifier/file-modification.js';
|
||||
|
||||
@@ -29,6 +30,11 @@ export class UnraidFileModificationService
|
||||
*/
|
||||
async onModuleInit() {
|
||||
try {
|
||||
if (NODE_ENV === 'development') {
|
||||
this.logger.log('Skipping file modifications in development mode');
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log('Loading file modifications...');
|
||||
const mods = await this.loadModifications();
|
||||
await this.applyModifications(mods);
|
||||
|
||||
@@ -29,26 +29,7 @@ const config: CodegenConfig = {
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
// Generate Types for Mothership GraphQL Client
|
||||
'src/graphql/generated/client/': {
|
||||
documents: './src/graphql/**/*.ts',
|
||||
schema: {
|
||||
[process.env.MOTHERSHIP_GRAPHQL_LINK ?? 'https://staging.mothership.unraid.net/ws']: {
|
||||
headers: {
|
||||
origin: 'https://forums.unraid.net',
|
||||
},
|
||||
},
|
||||
},
|
||||
preset: 'client',
|
||||
presetConfig: {
|
||||
gqlTagName: 'graphql',
|
||||
},
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
withObjectType: true,
|
||||
},
|
||||
plugins: [{ add: { content: '/* eslint-disable */' } }],
|
||||
},
|
||||
// No longer generating mothership GraphQL types since we switched to WebSocket-based UnraidServerClient
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"build": "tsc",
|
||||
"prepare": "npm run build",
|
||||
"format": "prettier --write \"src/**/*.{ts,js,json}\"",
|
||||
"codegen": "MOTHERSHIP_GRAPHQL_LINK='https://staging.mothership.unraid.net/ws' graphql-codegen --config codegen.ts"
|
||||
"codegen": "graphql-codegen --config codegen.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"unraid",
|
||||
@@ -57,6 +57,7 @@
|
||||
"jose": "6.0.13",
|
||||
"lodash-es": "4.17.21",
|
||||
"nest-authz": "2.17.0",
|
||||
"pify": "^6.1.0",
|
||||
"prettier": "3.6.2",
|
||||
"rimraf": "6.0.1",
|
||||
"rxjs": "7.8.2",
|
||||
|
||||
@@ -204,7 +204,7 @@ export class CloudService {
|
||||
}
|
||||
|
||||
private async hardCheckDns() {
|
||||
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_GRAPHQL_LINK');
|
||||
const mothershipGqlUri = this.configService.getOrThrow<string>('MOTHERSHIP_BASE_URL');
|
||||
const hostname = new URL(mothershipGqlUri).host;
|
||||
const lookup = promisify(lookupDNS);
|
||||
const resolve = promisify(resolveDNS);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { unlink, writeFile } from 'fs/promises';
|
||||
import { mkdir, unlink, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
|
||||
import { ConfigType, ConnectionMetadata } from '../config/connect.config.js';
|
||||
import { EVENTS } from '../helper/nest-tokens.js';
|
||||
@@ -13,8 +14,8 @@ export class ConnectStatusWriterService implements OnApplicationBootstrap, OnMod
|
||||
private logger = new Logger(ConnectStatusWriterService.name);
|
||||
|
||||
get statusFilePath() {
|
||||
// Use environment variable if provided, otherwise use default path
|
||||
return process.env.PATHS_CONNECT_STATUS_FILE_PATH ?? '/var/local/emhttp/connectStatus.json';
|
||||
// Use environment variable if set, otherwise default to /var/local/emhttp/connectStatus.json
|
||||
return this.configService.get('PATHS_CONNECT_STATUS') || '/var/local/emhttp/connectStatus.json';
|
||||
}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
@@ -59,6 +60,10 @@ export class ConnectStatusWriterService implements OnApplicationBootstrap, OnMod
|
||||
const data = JSON.stringify(statusData, null, 2);
|
||||
this.logger.verbose(`Writing connection status: ${data}`);
|
||||
|
||||
// Ensure the directory exists before writing
|
||||
const dir = dirname(this.statusFilePath);
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
await writeFile(this.statusFilePath, data);
|
||||
this.logger.verbose(`Status written to ${this.statusFilePath}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Import from the generated directory
|
||||
import { graphql } from '../graphql/generated/client/gql.js';
|
||||
import { graphql } from './generated/client/gql.js';
|
||||
|
||||
export const SEND_REMOTE_QUERY_RESPONSE = graphql(/* GraphQL */ `
|
||||
mutation sendRemoteGraphQLResponse($input: RemoteGraphQLServerInput!) {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { gql } from '@apollo/client/core/index.js';
|
||||
import { parse, print, visit } from 'graphql';
|
||||
|
||||
import {
|
||||
CANONICAL_INTERNAL_CLIENT_TOKEN,
|
||||
type CanonicalInternalClientService,
|
||||
} from '@unraid/shared';
|
||||
|
||||
interface GraphQLExecutor {
|
||||
execute(params: {
|
||||
query: string
|
||||
variables?: Record<string, any>
|
||||
operationName?: string
|
||||
operationType?: 'query' | 'mutation' | 'subscription'
|
||||
}): Promise<any>
|
||||
stopSubscription?(operationId: string): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Local GraphQL executor that maps remote queries to local API calls
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalGraphQLExecutor implements GraphQLExecutor {
|
||||
private readonly logger = new Logger(LocalGraphQLExecutor.name);
|
||||
|
||||
constructor(
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClient: CanonicalInternalClientService,
|
||||
) {}
|
||||
|
||||
async execute(params: {
|
||||
query: string
|
||||
variables?: Record<string, any>
|
||||
operationName?: string
|
||||
operationType?: 'query' | 'mutation' | 'subscription'
|
||||
}): Promise<any> {
|
||||
const { query, variables, operationName, operationType } = params;
|
||||
|
||||
try {
|
||||
this.logger.debug(`Executing ${operationType} operation: ${operationName || 'unnamed'}`);
|
||||
this.logger.verbose(`Query: ${query}`);
|
||||
this.logger.verbose(`Variables: ${JSON.stringify(variables)}`);
|
||||
|
||||
// Transform remote query to local query by removing "remote" prefixes
|
||||
const localQuery = this.transformRemoteQueryToLocal(query);
|
||||
|
||||
// Execute the transformed query against local API
|
||||
const client = await this.internalClient.getClient();
|
||||
const result = await client.query({
|
||||
query: gql`${localQuery}`,
|
||||
variables,
|
||||
});
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`GraphQL execution error: ${error?.message}`);
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: error?.message || 'Unknown error',
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform remote GraphQL query to local query by removing "remote" prefixes
|
||||
*/
|
||||
private transformRemoteQueryToLocal(query: string): string {
|
||||
try {
|
||||
// Parse the GraphQL query
|
||||
const document = parse(query);
|
||||
|
||||
// Transform the document by removing "remote" prefixes
|
||||
const transformedDocument = visit(document, {
|
||||
// Transform operation names (e.g., remoteGetDockerInfo -> getDockerInfo)
|
||||
OperationDefinition: (node) => {
|
||||
if (node.name?.value.startsWith('remote')) {
|
||||
return {
|
||||
...node,
|
||||
name: {
|
||||
...node.name,
|
||||
value: this.removeRemotePrefix(node.name.value),
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
},
|
||||
// Transform field names (e.g., remoteGetDockerInfo -> docker, remoteGetVms -> vms)
|
||||
Field: (node) => {
|
||||
if (node.name.value.startsWith('remote')) {
|
||||
return {
|
||||
...node,
|
||||
name: {
|
||||
...node.name,
|
||||
value: this.transformRemoteFieldName(node.name.value),
|
||||
},
|
||||
};
|
||||
}
|
||||
return node;
|
||||
},
|
||||
});
|
||||
|
||||
// Convert back to string
|
||||
return print(transformedDocument);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to parse/transform GraphQL query: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove "remote" prefix from operation names
|
||||
*/
|
||||
private removeRemotePrefix(name: string): string {
|
||||
if (name.startsWith('remote')) {
|
||||
// remoteGetDockerInfo -> getDockerInfo
|
||||
return name.slice(6); // Remove "remote"
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform remote field names to local equivalents
|
||||
*/
|
||||
private transformRemoteFieldName(fieldName: string): string {
|
||||
// Handle common patterns
|
||||
if (fieldName === 'remoteGetDockerInfo') {
|
||||
return 'docker';
|
||||
}
|
||||
if (fieldName === 'remoteGetVms') {
|
||||
return 'vms';
|
||||
}
|
||||
if (fieldName === 'remoteGetSystemInfo') {
|
||||
return 'system';
|
||||
}
|
||||
|
||||
// Generic transformation: remove "remoteGet" and convert to camelCase
|
||||
if (fieldName.startsWith('remoteGet')) {
|
||||
const baseName = fieldName.slice(9); // Remove "remoteGet"
|
||||
return baseName.charAt(0).toLowerCase() + baseName.slice(1);
|
||||
}
|
||||
|
||||
// Remove "remote" prefix as fallback
|
||||
if (fieldName.startsWith('remote')) {
|
||||
const baseName = fieldName.slice(6); // Remove "remote"
|
||||
return baseName.charAt(0).toLowerCase() + baseName.slice(1);
|
||||
}
|
||||
|
||||
return fieldName;
|
||||
}
|
||||
|
||||
async stopSubscription(operationId: string): Promise<void> {
|
||||
this.logger.debug(`Stopping subscription: ${operationId}`);
|
||||
// Subscription cleanup logic would go here
|
||||
}
|
||||
}
|
||||
@@ -14,207 +14,145 @@ import { useFragment } from '../graphql/generated/client/index.js';
|
||||
import { SEND_REMOTE_QUERY_RESPONSE } from '../graphql/remote-response.js';
|
||||
import { parseGraphQLQuery } from '../helper/parse-graphql.js';
|
||||
import { MothershipConnectionService } from './connection.service.js';
|
||||
import { MothershipGraphqlClientService } from './graphql.client.js';
|
||||
import { UnraidServerClientService } from './unraid-server-client.service.js';
|
||||
|
||||
type SubscriptionProxy = {
|
||||
interface SubscriptionInfo {
|
||||
sha256: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
type ActiveSubscription = {
|
||||
subscription: Subscription;
|
||||
createdAt: number;
|
||||
lastPing: number;
|
||||
};
|
||||
operationId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MothershipSubscriptionHandler {
|
||||
constructor(
|
||||
@Inject(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
||||
private readonly internalClientService: CanonicalInternalClientService,
|
||||
private readonly mothershipClient: MothershipGraphqlClientService,
|
||||
private readonly mothershipClient: UnraidServerClientService,
|
||||
private readonly connectionService: MothershipConnectionService
|
||||
) {}
|
||||
|
||||
private readonly logger = new Logger(MothershipSubscriptionHandler.name);
|
||||
private subscriptions: Map<string, ActiveSubscription> = new Map();
|
||||
private mothershipSubscription: Subscription | null = null;
|
||||
private readonly activeSubscriptions = new Map<string, SubscriptionInfo>();
|
||||
|
||||
removeSubscription(sha256: string) {
|
||||
this.subscriptions.get(sha256)?.subscription.unsubscribe();
|
||||
const removed = this.subscriptions.delete(sha256);
|
||||
// If this line outputs false, the subscription did not exist in the map.
|
||||
this.logger.debug(`Removed subscription ${sha256}: ${removed}`);
|
||||
this.logger.verbose(`Current active subscriptions: ${this.subscriptions.size}`);
|
||||
const subscription = this.activeSubscriptions.get(sha256);
|
||||
if (subscription) {
|
||||
this.logger.debug(`Removing subscription ${sha256}`);
|
||||
this.activeSubscriptions.delete(sha256);
|
||||
|
||||
// Stop the subscription via the UnraidServerClient if it has an operationId
|
||||
const client = this.mothershipClient.getClient();
|
||||
if (client && subscription.operationId) {
|
||||
// Note: We can't directly call stopSubscription on the client since it's private
|
||||
// This would need to be exposed or handled differently in a real implementation
|
||||
this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`);
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Subscription ${sha256} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
clearAllSubscriptions() {
|
||||
this.logger.verbose('Clearing all active subscriptions');
|
||||
this.subscriptions.forEach(({ subscription }) => {
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
this.subscriptions.clear();
|
||||
this.logger.verbose(`Current active subscriptions: ${this.subscriptions.size}`);
|
||||
this.logger.verbose(`Clearing ${this.activeSubscriptions.size} active subscriptions`);
|
||||
|
||||
// Stop all subscriptions via the UnraidServerClient
|
||||
const client = this.mothershipClient.getClient();
|
||||
if (client) {
|
||||
for (const [sha256, subscription] of this.activeSubscriptions.entries()) {
|
||||
if (subscription.operationId) {
|
||||
this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.activeSubscriptions.clear();
|
||||
}
|
||||
|
||||
clearStaleSubscriptions({ maxAgeMs }: { maxAgeMs: number }) {
|
||||
if (this.subscriptions.size === 0) {
|
||||
return;
|
||||
}
|
||||
const totalSubscriptions = this.subscriptions.size;
|
||||
let numOfStaleSubscriptions = 0;
|
||||
const now = Date.now();
|
||||
this.subscriptions
|
||||
.entries()
|
||||
.filter(([, { lastPing }]) => {
|
||||
return now - lastPing > maxAgeMs;
|
||||
})
|
||||
.forEach(([sha256]) => {
|
||||
const staleSubscriptions: string[] = [];
|
||||
|
||||
for (const [sha256, subscription] of this.activeSubscriptions.entries()) {
|
||||
const age = now - subscription.lastPing;
|
||||
if (age > maxAgeMs) {
|
||||
staleSubscriptions.push(sha256);
|
||||
}
|
||||
}
|
||||
|
||||
if (staleSubscriptions.length > 0) {
|
||||
this.logger.verbose(`Clearing ${staleSubscriptions.length} stale subscriptions older than ${maxAgeMs}ms`);
|
||||
|
||||
for (const sha256 of staleSubscriptions) {
|
||||
this.removeSubscription(sha256);
|
||||
numOfStaleSubscriptions++;
|
||||
});
|
||||
this.logger.verbose(
|
||||
`Cleared ${numOfStaleSubscriptions}/${totalSubscriptions} subscriptions (older than ${maxAgeMs}ms)`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.logger.verbose(`No stale subscriptions found (${this.activeSubscriptions.size} active)`);
|
||||
}
|
||||
}
|
||||
|
||||
pingSubscription(sha256: string) {
|
||||
const subscription = this.subscriptions.get(sha256);
|
||||
const subscription = this.activeSubscriptions.get(sha256);
|
||||
if (subscription) {
|
||||
subscription.lastPing = Date.now();
|
||||
this.logger.verbose(`Updated ping for subscription ${sha256}`);
|
||||
} else {
|
||||
this.logger.warn(`Subscription ${sha256} not found; cannot ping`);
|
||||
this.logger.verbose(`Ping for unknown subscription ${sha256}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async addSubscription({ sha256, body }: SubscriptionProxy) {
|
||||
if (this.subscriptions.has(sha256)) {
|
||||
throw new Error(`Subscription already exists for SHA256: ${sha256}`);
|
||||
}
|
||||
const parsedBody = parseGraphQLQuery(body);
|
||||
const client = await this.internalClientService.getClient();
|
||||
const observable = client.subscribe({
|
||||
query: parsedBody.query,
|
||||
variables: parsedBody.variables,
|
||||
});
|
||||
const subscription = observable.subscribe({
|
||||
next: async (val) => {
|
||||
this.logger.verbose(`Subscription ${sha256} received value: %O`, val);
|
||||
if (!val.data) return;
|
||||
const result = await this.mothershipClient.sendQueryResponse(sha256, {
|
||||
data: val.data,
|
||||
});
|
||||
this.logger.verbose(`Subscription ${sha256} published result: %O`, result);
|
||||
},
|
||||
error: async (err) => {
|
||||
this.logger.warn(`Subscription ${sha256} error: %O`, err);
|
||||
await this.mothershipClient.sendQueryResponse(sha256, {
|
||||
errors: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.subscriptions.set(sha256, {
|
||||
subscription,
|
||||
lastPing: Date.now(),
|
||||
});
|
||||
this.logger.verbose(`Added subscription ${sha256}`);
|
||||
return {
|
||||
addSubscription(sha256: string, operationId?: string) {
|
||||
const now = Date.now();
|
||||
const subscription: SubscriptionInfo = {
|
||||
sha256,
|
||||
subscription,
|
||||
createdAt: now,
|
||||
lastPing: now,
|
||||
operationId
|
||||
};
|
||||
}
|
||||
|
||||
async executeQuery(sha256: string, body: string) {
|
||||
const internalClient = await this.internalClientService.getClient();
|
||||
const parsedBody = parseGraphQLQuery(body);
|
||||
const queryInput = {
|
||||
query: parsedBody.query,
|
||||
variables: parsedBody.variables,
|
||||
};
|
||||
this.logger.verbose(`Executing query: %O`, queryInput);
|
||||
|
||||
const result = await internalClient.query(queryInput);
|
||||
if (result.error) {
|
||||
this.logger.warn(`Query returned error: %O`, result.error);
|
||||
this.mothershipClient.sendQueryResponse(sha256, {
|
||||
errors: result.error,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
this.mothershipClient.sendQueryResponse(sha256, {
|
||||
data: result.data,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async safeExecuteQuery(sha256: string, body: string) {
|
||||
try {
|
||||
return await this.executeQuery(sha256, body);
|
||||
} catch (error) {
|
||||
this.logger.error(error);
|
||||
this.mothershipClient.sendQueryResponse(sha256, {
|
||||
errors: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async handleRemoteGraphQLEvent(event: RemoteGraphQlEventFragmentFragment) {
|
||||
const { body, type, sha256 } = event.remoteGraphQLEventData;
|
||||
switch (type) {
|
||||
case RemoteGraphQlEventType.REMOTE_QUERY_EVENT:
|
||||
return this.safeExecuteQuery(sha256, body);
|
||||
case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT:
|
||||
return this.addSubscription(event.remoteGraphQLEventData);
|
||||
case RemoteGraphQlEventType.REMOTE_SUBSCRIPTION_EVENT_PING:
|
||||
return this.pingSubscription(sha256);
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeSubscriptions.set(sha256, subscription);
|
||||
this.logger.debug(`Added subscription ${sha256} ${operationId ? `with operationId: ${operationId}` : ''}`);
|
||||
}
|
||||
|
||||
stopMothershipSubscription() {
|
||||
this.mothershipSubscription?.unsubscribe();
|
||||
this.mothershipSubscription = null;
|
||||
this.logger.verbose('Stopping mothership subscription (not implemented yet)');
|
||||
}
|
||||
|
||||
async subscribeToMothershipEvents(client = this.mothershipClient.getClient()) {
|
||||
if (!client) {
|
||||
this.logger.error('Mothership client unavailable. State might not be loaded.');
|
||||
return;
|
||||
async subscribeToMothershipEvents() {
|
||||
this.logger.log('Subscribing to mothership events via UnraidServerClient');
|
||||
|
||||
// For now, just log that we're connected
|
||||
// The UnraidServerClient handles the WebSocket connection automatically
|
||||
const client = this.mothershipClient.getClient();
|
||||
if (client) {
|
||||
this.logger.log('UnraidServerClient is connected and handling mothership communication');
|
||||
} else {
|
||||
this.logger.warn('UnraidServerClient is not available');
|
||||
}
|
||||
const subscription = client.subscribe({
|
||||
query: EVENTS_SUBSCRIPTION,
|
||||
fetchPolicy: 'no-cache',
|
||||
});
|
||||
this.mothershipSubscription = subscription.subscribe({
|
||||
next: (event) => {
|
||||
if (event.errors) {
|
||||
this.logger.error(`Error received from mothership: %O`, event.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
async executeQuery(sha256: string, body: string) {
|
||||
this.logger.debug(`Request to execute query ${sha256}: ${body} (simplified implementation)`);
|
||||
|
||||
try {
|
||||
// For now, just return a success response
|
||||
// TODO: Implement actual query execution via the UnraidServerClient
|
||||
return {
|
||||
data: {
|
||||
message: 'Query executed successfully (simplified)',
|
||||
sha256,
|
||||
}
|
||||
if (!event.data) return;
|
||||
const { events } = event.data;
|
||||
for (const event of events?.filter(isDefined) ?? []) {
|
||||
const { __typename: eventType } = event;
|
||||
if (eventType === 'ClientConnectedEvent') {
|
||||
if (
|
||||
event.connectedData.type === ClientType.API &&
|
||||
event.connectedData.apiKey === this.connectionService.getApiKey()
|
||||
) {
|
||||
this.connectionService.clearDisconnectedTimestamp();
|
||||
}
|
||||
} else if (eventType === 'ClientDisconnectedEvent') {
|
||||
if (
|
||||
event.disconnectedData.type === ClientType.API &&
|
||||
event.disconnectedData.apiKey === this.connectionService.getApiKey()
|
||||
) {
|
||||
this.connectionService.setDisconnectedTimestamp();
|
||||
}
|
||||
} else if (eventType === 'RemoteGraphQLEvent') {
|
||||
const remoteGraphQLEvent = useFragment(RemoteGraphQL_Fragment, event);
|
||||
return this.handleRemoteGraphQLEvent(remoteGraphQLEvent);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error executing query ${sha256}:`, error);
|
||||
return {
|
||||
errors: [
|
||||
{
|
||||
message: `Query execution failed: ${error?.message || 'Unknown error'}`,
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@ne
|
||||
|
||||
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
|
||||
import { MothershipConnectionService } from './connection.service.js';
|
||||
import { MothershipGraphqlClientService } from './graphql.client.js';
|
||||
import { UnraidServerClientService } from './unraid-server-client.service.js';
|
||||
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
|
||||
|
||||
/**
|
||||
* Controller for (starting and stopping) the mothership stack:
|
||||
* - GraphQL client (to mothership)
|
||||
* - UnraidServerClient (websocket communication with mothership)
|
||||
* - Subscription handler (websocket communication with mothership)
|
||||
* - Timeout checker (to detect if the connection to mothership is lost)
|
||||
* - Connection service (controller for connection state & metadata)
|
||||
@@ -16,7 +16,7 @@ import { MothershipSubscriptionHandler } from './mothership-subscription.handler
|
||||
export class MothershipController implements OnModuleDestroy, OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(MothershipController.name);
|
||||
constructor(
|
||||
private readonly clientService: MothershipGraphqlClientService,
|
||||
private readonly clientService: UnraidServerClientService,
|
||||
private readonly connectionService: MothershipConnectionService,
|
||||
private readonly subscriptionHandler: MothershipSubscriptionHandler,
|
||||
private readonly timeoutCheckerJob: TimeoutCheckerJob
|
||||
@@ -36,7 +36,9 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots
|
||||
async stop() {
|
||||
this.timeoutCheckerJob.stop();
|
||||
this.subscriptionHandler.stopMothershipSubscription();
|
||||
await this.clientService.clearInstance();
|
||||
if (this.clientService.getClient()) {
|
||||
this.clientService.getClient()?.disconnect();
|
||||
}
|
||||
this.connectionService.resetMetadata();
|
||||
this.subscriptionHandler.clearAllSubscriptions();
|
||||
}
|
||||
@@ -46,13 +48,13 @@ export class MothershipController implements OnModuleDestroy, OnApplicationBoots
|
||||
*/
|
||||
async initOrRestart() {
|
||||
await this.stop();
|
||||
const { state } = this.connectionService.getIdentityState();
|
||||
const identityState = this.connectionService.getIdentityState();
|
||||
this.logger.verbose('cleared, got identity state');
|
||||
if (!state.apiKey) {
|
||||
this.logger.warn('No API key found; cannot setup mothership subscription');
|
||||
if (!identityState.isLoaded || !identityState.state.apiKey) {
|
||||
this.logger.warn('No API key found; cannot setup mothership connection');
|
||||
return;
|
||||
}
|
||||
await this.clientService.createClientInstance();
|
||||
await this.clientService.reconnect();
|
||||
await this.subscriptionHandler.subscribeToMothershipEvents();
|
||||
this.timeoutCheckerJob.start();
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
|
||||
import { CloudResolver } from '../connection-status/cloud.resolver.js';
|
||||
import { CloudService } from '../connection-status/cloud.service.js';
|
||||
import { ConnectStatusWriterService } from '../connection-status/connect-status-writer.service.js';
|
||||
import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
|
||||
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
|
||||
import { MothershipConnectionService } from './connection.service.js';
|
||||
import { MothershipGraphqlClientService } from './graphql.client.js';
|
||||
import { LocalGraphQLExecutor } from './local-graphql-executor.service.js';
|
||||
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
|
||||
import { MothershipController } from './mothership.controller.js';
|
||||
import { MothershipHandler } from './mothership.events.js';
|
||||
import { UnraidServerClientService } from './unraid-server-client.service.js';
|
||||
|
||||
@Module({
|
||||
imports: [RemoteAccessModule],
|
||||
providers: [
|
||||
ConnectStatusWriterService,
|
||||
MothershipConnectionService,
|
||||
MothershipGraphqlClientService,
|
||||
LocalGraphQLExecutor,
|
||||
UnraidServerClientService,
|
||||
MothershipHandler,
|
||||
MothershipSubscriptionHandler,
|
||||
TimeoutCheckerJob,
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
import { MothershipConnectionService } from './connection.service.js';
|
||||
import { LocalGraphQLExecutor } from './local-graphql-executor.service.js';
|
||||
|
||||
/**
|
||||
* Unraid server client for connecting to the new mothership architecture
|
||||
* This handles GraphQL requests from the mothership and executes them using a local Apollo client
|
||||
*/
|
||||
|
||||
|
||||
|
||||
interface GraphQLResponse {
|
||||
operationId: string
|
||||
messageId?: string
|
||||
event: 'query_response'
|
||||
type: 'data' | 'error' | 'complete'
|
||||
payload: any
|
||||
requestHash?: string
|
||||
}
|
||||
|
||||
interface GraphQLExecutor {
|
||||
execute(params: {
|
||||
query: string
|
||||
variables?: Record<string, any>
|
||||
operationName?: string
|
||||
operationType?: 'query' | 'mutation' | 'subscription'
|
||||
}): Promise<any>
|
||||
stopSubscription?(operationId: string): Promise<void>
|
||||
}
|
||||
|
||||
|
||||
export class UnraidServerClient {
|
||||
private ws: WebSocket | null = null
|
||||
private reconnectAttempts = 0
|
||||
private readonly initialReconnectDelay = 1000 // 1 second
|
||||
private readonly maxReconnectDelay = 30 * 60 * 1000 // 30 minutes
|
||||
private pingInterval: NodeJS.Timeout | null = null
|
||||
private reconnectTimeout: NodeJS.Timeout | null = null
|
||||
private shouldReconnect = true
|
||||
|
||||
constructor(
|
||||
private mothershipUrl: string,
|
||||
private apiKey: string,
|
||||
private executor: GraphQLExecutor,
|
||||
) {}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
this.shouldReconnect = true
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const wsUrl = `${this.mothershipUrl}/ws/server`
|
||||
this.ws = new WebSocket(wsUrl, [], {
|
||||
headers: {
|
||||
'X-API-Key': this.apiKey,
|
||||
},
|
||||
})
|
||||
|
||||
this.ws.onopen = () => {
|
||||
console.log('Connected to mothership')
|
||||
this.reconnectAttempts = 0
|
||||
this.setupPingInterval()
|
||||
resolve()
|
||||
}
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const data = typeof event.data === 'string' ? event.data : event.data.toString()
|
||||
this.handleGraphQLRequest(data)
|
||||
}
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
console.log('Disconnected from mothership:', event.code, event.reason)
|
||||
this.clearPingInterval()
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect()
|
||||
} else {
|
||||
console.log('Reconnection disabled, not scheduling reconnect')
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
reject(error)
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async handleGraphQLRequest(data: string) {
|
||||
try {
|
||||
// Handle plaintext ping/pong messages first
|
||||
if (data.trim() === 'ping') {
|
||||
this.sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
if (data.trim() === 'pong') {
|
||||
console.log('Received pong from mothership')
|
||||
return
|
||||
}
|
||||
|
||||
// Try to parse as JSON for structured messages
|
||||
let message: any
|
||||
try {
|
||||
message = JSON.parse(data)
|
||||
} catch (parseError) {
|
||||
// Not valid JSON, could be other plaintext message
|
||||
console.log('Received non-JSON message from mothership:', data.trim())
|
||||
return
|
||||
}
|
||||
|
||||
// Handle JSON ping/pong messages (fallback)
|
||||
if (message.type === 'ping' || message.ping) {
|
||||
this.sendPong()
|
||||
return
|
||||
}
|
||||
|
||||
if (message.type === 'pong' || message.pong || JSON.stringify(message) === '"pong"') {
|
||||
console.log('Received pong from mothership')
|
||||
return
|
||||
}
|
||||
|
||||
// Handle new event-based GraphQL requests
|
||||
if (message.event === 'remote_query' || message.event === 'subscription_start' || message.event === 'subscription_stop') {
|
||||
await this.handleNewFormatGraphQLRequest(message)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle messages routed from RouterDO
|
||||
if (message.event === 'route_message') {
|
||||
await this.handleRouteMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle request type messages (legacy format)
|
||||
if (message.type === 'request') {
|
||||
await this.handleRequestMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle unknown message types
|
||||
console.warn('Unknown message event received from mothership:', message.event || message.type, JSON.stringify(message).substring(0, 200))
|
||||
} catch (error: any) {
|
||||
console.error('Error handling GraphQL request:', error)
|
||||
|
||||
// Send error response if possible
|
||||
try {
|
||||
const errorRequest = JSON.parse(data)
|
||||
// Only send error response for GraphQL requests that have operationId
|
||||
if (errorRequest.operationId && (errorRequest.event === 'remote_query' || errorRequest.event === 'route_message')) {
|
||||
const operationId = errorRequest.operationId || `error-${Date.now()}`
|
||||
this.sendResponse({
|
||||
operationId,
|
||||
event: 'query_response',
|
||||
type: 'error',
|
||||
payload: {
|
||||
errors: [
|
||||
{
|
||||
message: error?.message || 'Unknown error',
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to send error response:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendResponse(response: GraphQLResponse) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(response))
|
||||
}
|
||||
}
|
||||
|
||||
private sendPong() {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
// Send plaintext pong response
|
||||
this.ws.send('pong')
|
||||
}
|
||||
}
|
||||
|
||||
private setupPingInterval() {
|
||||
this.clearPingInterval()
|
||||
// Send ping every 30 seconds to keep connection alive
|
||||
this.pingInterval = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
// Send plaintext ping
|
||||
this.ws.send('ping')
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
private clearPingInterval() {
|
||||
if (this.pingInterval) {
|
||||
clearInterval(this.pingInterval)
|
||||
this.pingInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (!this.shouldReconnect) {
|
||||
console.log('Reconnection disabled, not scheduling reconnect')
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
|
||||
// Calculate exponential backoff delay: 1s, 2s, 4s, 8s, 16s, 32s, etc.
|
||||
// Cap at maxReconnectDelay (30 minutes)
|
||||
const exponentialDelay = this.initialReconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
const delay = Math.min(exponentialDelay, this.maxReconnectDelay)
|
||||
|
||||
console.log(
|
||||
`Scheduling reconnection attempt ${this.reconnectAttempts} in ${delay / 1000}s (${Math.floor(delay / 60000)}m ${Math.floor((delay % 60000) / 1000)}s)`
|
||||
)
|
||||
|
||||
// Clear any existing reconnect timeout
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
}
|
||||
|
||||
this.reconnectTimeout = setTimeout(
|
||||
() => {
|
||||
if (!this.shouldReconnect) {
|
||||
console.log('Reconnection disabled, skipping attempt')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Reconnection attempt ${this.reconnectAttempts}`)
|
||||
this.connect().catch((error) => {
|
||||
console.error('Reconnection failed:', error)
|
||||
// Schedule next reconnection attempt
|
||||
this.scheduleReconnect()
|
||||
})
|
||||
},
|
||||
delay
|
||||
)
|
||||
}
|
||||
|
||||
private async handleNewFormatGraphQLRequest(message: any) {
|
||||
if (!message.payload || !message.payload.query) {
|
||||
console.warn('Invalid GraphQL request - missing payload or query:', message)
|
||||
return
|
||||
}
|
||||
|
||||
const operationId = message.operationId || `auto-${Date.now()}`
|
||||
const messageId = message.messageId || `msg_${operationId}_${Date.now()}`
|
||||
|
||||
// Handle subscription stop
|
||||
if (message.event === 'subscription_stop') {
|
||||
if (this.executor.stopSubscription) {
|
||||
await this.executor.stopSubscription(operationId)
|
||||
}
|
||||
this.sendResponse({
|
||||
operationId,
|
||||
messageId,
|
||||
event: 'query_response',
|
||||
type: 'complete',
|
||||
payload: { data: null },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Execute GraphQL operation for remote_query and subscription_start events
|
||||
if (message.event === 'remote_query' || message.event === 'subscription_start') {
|
||||
try {
|
||||
const operationType = message.event === 'subscription_start' ? 'subscription' : 'query'
|
||||
const result = await this.executor.execute({
|
||||
query: message.payload.query,
|
||||
variables: message.payload.variables,
|
||||
operationName: message.payload.operationName,
|
||||
operationType,
|
||||
})
|
||||
|
||||
// Send response back to mothership
|
||||
const response: GraphQLResponse = {
|
||||
operationId,
|
||||
messageId: `msg_response_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: result.errors ? 'error' : 'data',
|
||||
payload: result,
|
||||
}
|
||||
|
||||
this.sendResponse(response)
|
||||
} catch (error: any) {
|
||||
this.sendResponse({
|
||||
operationId,
|
||||
messageId: `msg_error_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: 'error',
|
||||
payload: {
|
||||
errors: [
|
||||
{
|
||||
message: error?.message || 'Unknown error',
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRouteMessage(message: any) {
|
||||
if (!message.payload || !message.payload.query) {
|
||||
console.warn('Invalid route message - missing payload or query:', message)
|
||||
return
|
||||
}
|
||||
|
||||
const operationId = message.operationId || `auto-${Date.now()}`
|
||||
|
||||
try {
|
||||
const result = await this.executor.execute({
|
||||
query: message.payload.query,
|
||||
variables: message.payload.variables,
|
||||
operationName: message.payload.operationName,
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
// Send response back to mothership
|
||||
const response: GraphQLResponse = {
|
||||
operationId,
|
||||
messageId: `msg_response_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: result.errors ? 'error' : 'data',
|
||||
payload: result,
|
||||
}
|
||||
|
||||
this.sendResponse(response)
|
||||
} catch (error: any) {
|
||||
this.sendResponse({
|
||||
operationId,
|
||||
messageId: `msg_error_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: 'error',
|
||||
payload: {
|
||||
errors: [
|
||||
{
|
||||
message: error?.message || 'Unknown error',
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequestMessage(message: any) {
|
||||
if (!message.payload || !message.payload.query) {
|
||||
console.warn('Invalid request message - missing payload or query:', message)
|
||||
return
|
||||
}
|
||||
|
||||
const operationId = message.operationId || `auto-${Date.now()}`
|
||||
|
||||
try {
|
||||
const result = await this.executor.execute({
|
||||
query: message.payload.query,
|
||||
variables: message.payload.variables,
|
||||
operationName: message.payload.operationName,
|
||||
operationType: 'query',
|
||||
})
|
||||
|
||||
// Send response back to mothership
|
||||
const response: GraphQLResponse = {
|
||||
operationId,
|
||||
messageId: `msg_response_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: result.errors ? 'error' : 'data',
|
||||
payload: result,
|
||||
}
|
||||
|
||||
this.sendResponse(response)
|
||||
} catch (error: any) {
|
||||
this.sendResponse({
|
||||
operationId,
|
||||
messageId: `msg_error_${Date.now()}`,
|
||||
event: 'query_response',
|
||||
type: 'error',
|
||||
payload: {
|
||||
errors: [
|
||||
{
|
||||
message: error?.message || 'Unknown error',
|
||||
extensions: { code: 'EXECUTION_ERROR' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.shouldReconnect = false
|
||||
this.clearPingInterval()
|
||||
|
||||
// Clear any pending reconnection attempts
|
||||
if (this.reconnectTimeout) {
|
||||
clearTimeout(this.reconnectTimeout)
|
||||
this.reconnectTimeout = null
|
||||
}
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
|
||||
console.log('Disconnected from mothership (reconnection disabled)')
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UnraidServerClientService implements OnModuleInit, OnModuleDestroy {
|
||||
private logger = new Logger(UnraidServerClientService.name);
|
||||
private client: UnraidServerClient | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly connectionService: MothershipConnectionService,
|
||||
private readonly localExecutor: LocalGraphQLExecutor
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// Initialize the client when the module starts
|
||||
await this.initializeClient();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.disconnect();
|
||||
this.client = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeClient(): Promise<void> {
|
||||
try {
|
||||
const mothershipUrl = this.configService.getOrThrow('MOTHERSHIP_GRAPHQL_LINK');
|
||||
const identityState = this.connectionService.getIdentityState();
|
||||
|
||||
if (!identityState.isLoaded || !identityState.state.apiKey) {
|
||||
this.logger.warn('No API key available, cannot initialize UnraidServerClient');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the injected LocalGraphQLExecutor
|
||||
const executor = this.localExecutor;
|
||||
|
||||
this.client = new UnraidServerClient(
|
||||
mothershipUrl,
|
||||
identityState.state.apiKey,
|
||||
executor
|
||||
);
|
||||
|
||||
await this.client.connect();
|
||||
this.logger.log('UnraidServerClient connected successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize UnraidServerClient:', error);
|
||||
}
|
||||
}
|
||||
|
||||
getClient(): UnraidServerClient | null {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async reconnect(): Promise<void> {
|
||||
if (this.client) {
|
||||
this.client.disconnect();
|
||||
}
|
||||
await this.initializeClient();
|
||||
}
|
||||
}
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -612,6 +612,9 @@ importers:
|
||||
nest-authz:
|
||||
specifier: 2.17.0
|
||||
version: 2.17.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2)
|
||||
pify:
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0
|
||||
prettier:
|
||||
specifier: 3.6.2
|
||||
version: 3.6.2
|
||||
@@ -12434,8 +12437,8 @@ packages:
|
||||
vue-component-type-helpers@3.0.6:
|
||||
resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==}
|
||||
|
||||
vue-component-type-helpers@3.1.3:
|
||||
resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==}
|
||||
vue-component-type-helpers@3.1.5:
|
||||
resolution: {integrity: sha512-7V3yJuNWW7/1jxCcI1CswnpDsvs02Qcx/N43LkV+ZqhLj2PKj50slUflHAroNkN4UWiYfzMUUUXiNuv9khmSpQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -16500,7 +16503,7 @@ snapshots:
|
||||
storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.20(typescript@5.9.2)
|
||||
vue-component-type-helpers: 3.1.3
|
||||
vue-component-type-helpers: 3.1.5
|
||||
|
||||
'@swc/core-darwin-arm64@1.13.5':
|
||||
optional: true
|
||||
@@ -17358,7 +17361,7 @@ snapshots:
|
||||
'@vitest/snapshot@3.2.4':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 3.2.4
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@3.2.4':
|
||||
@@ -25280,13 +25283,13 @@ snapshots:
|
||||
chai: 5.2.0
|
||||
debug: 4.4.1(supports-color@5.5.0)
|
||||
expect-type: 1.2.1
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.9.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 0.3.2
|
||||
tinyglobby: 0.2.14
|
||||
tinyglobby: 0.2.15
|
||||
tinypool: 1.1.1
|
||||
tinyrainbow: 2.0.0
|
||||
vite: 7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
|
||||
@@ -25339,7 +25342,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.0.6: {}
|
||||
|
||||
vue-component-type-helpers@3.1.3: {}
|
||||
vue-component-type-helpers@3.1.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)):
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user