Skip to content

Commit 9ab5977

Browse files
committed
feat: mothership working e2e
1 parent 7bce139 commit 9ab5977

File tree

9 files changed

+525
-162
lines changed

9 files changed

+525
-162
lines changed

.claude/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@
1717
"Bash(pnpm --filter \"*connect*\" test connect-status-writer.service.spec)",
1818
"Bash(npx tsc:*)",
1919
"Bash(npx eslint:*)",
20-
"Bash(rm:*)"
20+
"Bash(rm:*)",
21+
"Bash(pnpm --filter ./packages/unraid-api-plugin-connect build)",
22+
"Bash(pnpm tsc:*)"
2123
]
2224
},
2325
"enableAllProjectMcpServers": false

api/dev/configs/connect.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"wanaccess": true,
33
"wanport": 8443,
44
"upnpEnabled": false,
5-
"apikey": "test-key-123",
5+
"apikey": "test-api-key-12345",
66
"localApiKey": "_______________________LOCAL_API_KEY_HERE_________________________",
77
"email": "[email protected]",
88
"username": "zspearmint",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"createdAt": "2025-07-19T22:29:38.790Z",
3+
"description": "Internal API Key Used By Unraid Connect to access your server resources for the connect.myunraid.net dashboard",
4+
"id": "7789353b-40f4-4f3b-a230-b1f22909abff",
5+
"key": "e6e0212193fa1cb468194dd5a4e41196305bc3b5e38532c2f86935bbde317bd0",
6+
"name": "ConnectInternal",
7+
"permissions": [],
8+
"roles": [
9+
"CONNECT"
10+
]
11+
}

api/dev/keys/b5b4aa3d-8e40-4c92-bc40-d50182071886.json

Lines changed: 0 additions & 11 deletions
This file was deleted.

api/dev/states/connectStatus.json

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { gql } from '@apollo/client/core/index.js';
3+
import { parse, print, visit } from 'graphql';
4+
5+
import { InternalClientService } from '../internal-rpc/internal.client.js';
6+
7+
interface GraphQLExecutor {
8+
execute(params: {
9+
query: string
10+
variables?: Record<string, any>
11+
operationName?: string
12+
operationType?: 'query' | 'mutation' | 'subscription'
13+
}): Promise<any>
14+
stopSubscription?(operationId: string): Promise<void>
15+
}
16+
17+
/**
18+
* Local GraphQL executor that maps remote queries to local API calls
19+
*/
20+
@Injectable()
21+
export class LocalGraphQLExecutor implements GraphQLExecutor {
22+
private logger = new Logger('LocalGraphQLExecutor');
23+
24+
constructor(private readonly internalClient: InternalClientService) {}
25+
26+
async execute(params: {
27+
query: string
28+
variables?: Record<string, any>
29+
operationName?: string
30+
operationType?: 'query' | 'mutation' | 'subscription'
31+
}): Promise<any> {
32+
const { query, variables, operationName, operationType } = params;
33+
34+
try {
35+
this.logger.debug(`Executing ${operationType} operation: ${operationName || 'unnamed'}`);
36+
this.logger.verbose(`Query: ${query}`);
37+
this.logger.verbose(`Variables: ${JSON.stringify(variables)}`);
38+
39+
// Transform remote query to local query by removing "remote" prefixes
40+
const localQuery = this.transformRemoteQueryToLocal(query);
41+
42+
// Execute the transformed query against local API
43+
const client = await this.internalClient.getClient();
44+
const result = await client.query({
45+
query: gql`${localQuery}`,
46+
variables,
47+
});
48+
49+
return {
50+
data: result.data,
51+
};
52+
} catch (error: any) {
53+
this.logger.error(`GraphQL execution error: ${error?.message}`);
54+
return {
55+
errors: [
56+
{
57+
message: error?.message || 'Unknown error',
58+
extensions: { code: 'EXECUTION_ERROR' },
59+
},
60+
],
61+
};
62+
}
63+
}
64+
65+
/**
66+
* Transform remote GraphQL query to local query by removing "remote" prefixes
67+
*/
68+
private transformRemoteQueryToLocal(query: string): string {
69+
try {
70+
// Parse the GraphQL query
71+
const document = parse(query);
72+
73+
// Transform the document by removing "remote" prefixes
74+
const transformedDocument = visit(document, {
75+
// Transform operation names (e.g., remoteGetDockerInfo -> getDockerInfo)
76+
OperationDefinition: (node) => {
77+
if (node.name?.value.startsWith('remote')) {
78+
return {
79+
...node,
80+
name: {
81+
...node.name,
82+
value: this.removeRemotePrefix(node.name.value),
83+
},
84+
};
85+
}
86+
return node;
87+
},
88+
// Transform field names (e.g., remoteGetDockerInfo -> docker, remoteGetVms -> vms)
89+
Field: (node) => {
90+
if (node.name.value.startsWith('remote')) {
91+
return {
92+
...node,
93+
name: {
94+
...node.name,
95+
value: this.transformRemoteFieldName(node.name.value),
96+
},
97+
};
98+
}
99+
return node;
100+
},
101+
});
102+
103+
// Convert back to string
104+
return print(transformedDocument);
105+
} catch (error) {
106+
this.logger.error(`Failed to parse/transform GraphQL query: ${error}`);
107+
throw error;
108+
}
109+
}
110+
111+
/**
112+
* Remove "remote" prefix from operation names
113+
*/
114+
private removeRemotePrefix(name: string): string {
115+
if (name.startsWith('remote')) {
116+
// remoteGetDockerInfo -> getDockerInfo
117+
return name.slice(6); // Remove "remote"
118+
}
119+
return name;
120+
}
121+
122+
/**
123+
* Transform remote field names to local equivalents
124+
*/
125+
private transformRemoteFieldName(fieldName: string): string {
126+
// Handle common patterns
127+
if (fieldName === 'remoteGetDockerInfo') {
128+
return 'docker';
129+
}
130+
if (fieldName === 'remoteGetVms') {
131+
return 'vms';
132+
}
133+
if (fieldName === 'remoteGetSystemInfo') {
134+
return 'system';
135+
}
136+
137+
// Generic transformation: remove "remoteGet" and convert to camelCase
138+
if (fieldName.startsWith('remoteGet')) {
139+
const baseName = fieldName.slice(9); // Remove "remoteGet"
140+
return baseName.charAt(0).toLowerCase() + baseName.slice(1);
141+
}
142+
143+
// Remove "remote" prefix as fallback
144+
if (fieldName.startsWith('remote')) {
145+
const baseName = fieldName.slice(6); // Remove "remote"
146+
return baseName.charAt(0).toLowerCase() + baseName.slice(1);
147+
}
148+
149+
return fieldName;
150+
}
151+
152+
async stopSubscription(operationId: string): Promise<void> {
153+
this.logger.debug(`Stopping subscription: ${operationId}`);
154+
// Subscription cleanup logic would go here
155+
}
156+
}

packages/unraid-api-plugin-connect/src/mothership-proxy/mothership-subscription.handler.ts

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ import { InternalClientService } from '../internal-rpc/internal.client.js';
44
import { MothershipConnectionService } from './connection.service.js';
55
import { UnraidServerClientService } from './unraid-server-client.service.js';
66

7+
interface SubscriptionInfo {
8+
sha256: string;
9+
createdAt: number;
10+
lastPing: number;
11+
operationId?: string;
12+
}
13+
714
@Injectable()
815
export class MothershipSubscriptionHandler {
916
constructor(
@@ -13,21 +20,85 @@ export class MothershipSubscriptionHandler {
1320
) {}
1421

1522
private readonly logger = new Logger(MothershipSubscriptionHandler.name);
23+
private readonly activeSubscriptions = new Map<string, SubscriptionInfo>();
1624

1725
removeSubscription(sha256: string) {
18-
this.logger.debug(`Request to remove subscription ${sha256} (not implemented yet)`);
26+
const subscription = this.activeSubscriptions.get(sha256);
27+
if (subscription) {
28+
this.logger.debug(`Removing subscription ${sha256}`);
29+
this.activeSubscriptions.delete(sha256);
30+
31+
// Stop the subscription via the UnraidServerClient if it has an operationId
32+
const client = this.mothershipClient.getClient();
33+
if (client && subscription.operationId) {
34+
// Note: We can't directly call stopSubscription on the client since it's private
35+
// This would need to be exposed or handled differently in a real implementation
36+
this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`);
37+
}
38+
} else {
39+
this.logger.debug(`Subscription ${sha256} not found`);
40+
}
1941
}
2042

2143
clearAllSubscriptions() {
22-
this.logger.verbose('Request to clear all active subscriptions (not implemented yet)');
44+
this.logger.verbose(`Clearing ${this.activeSubscriptions.size} active subscriptions`);
45+
46+
// Stop all subscriptions via the UnraidServerClient
47+
const client = this.mothershipClient.getClient();
48+
if (client) {
49+
for (const [sha256, subscription] of this.activeSubscriptions.entries()) {
50+
if (subscription.operationId) {
51+
this.logger.debug(`Should stop subscription with operationId: ${subscription.operationId}`);
52+
}
53+
}
54+
}
55+
56+
this.activeSubscriptions.clear();
2357
}
2458

2559
clearStaleSubscriptions({ maxAgeMs }: { maxAgeMs: number }) {
26-
this.logger.verbose(`Request to clear stale subscriptions older than ${maxAgeMs}ms (not implemented yet)`);
60+
const now = Date.now();
61+
const staleSubscriptions: string[] = [];
62+
63+
for (const [sha256, subscription] of this.activeSubscriptions.entries()) {
64+
const age = now - subscription.lastPing;
65+
if (age > maxAgeMs) {
66+
staleSubscriptions.push(sha256);
67+
}
68+
}
69+
70+
if (staleSubscriptions.length > 0) {
71+
this.logger.verbose(`Clearing ${staleSubscriptions.length} stale subscriptions older than ${maxAgeMs}ms`);
72+
73+
for (const sha256 of staleSubscriptions) {
74+
this.removeSubscription(sha256);
75+
}
76+
} else {
77+
this.logger.verbose(`No stale subscriptions found (${this.activeSubscriptions.size} active)`);
78+
}
2779
}
2880

2981
pingSubscription(sha256: string) {
30-
this.logger.verbose(`Ping subscription ${sha256} (not implemented yet)`);
82+
const subscription = this.activeSubscriptions.get(sha256);
83+
if (subscription) {
84+
subscription.lastPing = Date.now();
85+
this.logger.verbose(`Updated ping for subscription ${sha256}`);
86+
} else {
87+
this.logger.verbose(`Ping for unknown subscription ${sha256}`);
88+
}
89+
}
90+
91+
addSubscription(sha256: string, operationId?: string) {
92+
const now = Date.now();
93+
const subscription: SubscriptionInfo = {
94+
sha256,
95+
createdAt: now,
96+
lastPing: now,
97+
operationId
98+
};
99+
100+
this.activeSubscriptions.set(sha256, subscription);
101+
this.logger.debug(`Added subscription ${sha256} ${operationId ? `with operationId: ${operationId}` : ''}`);
31102
}
32103

33104
stopMothershipSubscription() {

packages/unraid-api-plugin-connect/src/mothership-proxy/mothership.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { TimeoutCheckerJob } from '../connection-status/timeout-checker.job.js';
88
import { InternalClientService } from '../internal-rpc/internal.client.js';
99
import { RemoteAccessModule } from '../remote-access/remote-access.module.js';
1010
import { MothershipConnectionService } from './connection.service.js';
11+
import { LocalGraphQLExecutor } from './local-graphql-executor.service.js';
1112
import { MothershipSubscriptionHandler } from './mothership-subscription.handler.js';
1213
import { MothershipController } from './mothership.controller.js';
1314
import { MothershipHandler } from './mothership.events.js';
@@ -21,6 +22,7 @@ import { UnraidServerClientService } from './unraid-server-client.service.js';
2122
MothershipConnectionService,
2223
UnraidServerClientService,
2324
InternalClientService,
25+
LocalGraphQLExecutor,
2426
MothershipHandler,
2527
MothershipSubscriptionHandler,
2628
TimeoutCheckerJob,

0 commit comments

Comments
 (0)