@masonator/coolify-mcp 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,17 @@
1
- [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stumason-coolify-mcp-badge.png)](https://mseep.ai/app/stumason-coolify-mcp)
2
-
3
1
  # Coolify MCP Server
4
2
 
3
+ [![MseeP.ai Security Assessment Badge](https://mseep.net/pr/stumason-coolify-mcp-badge.png)](https://mseep.ai/app/stumason-coolify-mcp)
4
+
5
5
  A Model Context Protocol (MCP) server for [Coolify](https://coolify.io/), enabling AI assistants to manage and debug your Coolify instances through natural language.
6
6
 
7
7
  ## Features
8
8
 
9
- This MCP server provides **58 tools** focused on **debugging, management, and deployment**:
9
+ This MCP server provides **61 tools** focused on **debugging, management, and deployment**:
10
10
 
11
11
  | Category | Tools |
12
12
  | ------------------ | -------------------------------------------------------------------------------------------------------- |
13
13
  | **Infrastructure** | overview (all resources at once) |
14
+ | **Diagnostics** | diagnose_app, diagnose_server, find_issues (smart lookup by name/domain/IP) |
14
15
  | **Servers** | list, get, validate, resources, domains |
15
16
  | **Projects** | list, get, create, update, delete |
16
17
  | **Environments** | list, get, create, delete |
@@ -82,11 +83,12 @@ The Coolify API returns extremely verbose responses - a single application can c
82
83
 
83
84
  ### Response Size Comparison
84
85
 
85
- | Endpoint | Full Response | Summary Response | Reduction |
86
- | ----------------- | ------------- | ---------------- | --------- |
87
- | list_applications | ~170KB | ~4.4KB | **97%** |
88
- | list_services | ~367KB | ~1.2KB | **99%** |
89
- | list_servers | ~4KB | ~0.4KB | **90%** |
86
+ | Endpoint | Full Response | Summary Response | Reduction |
87
+ | --------------------- | ------------- | ---------------- | --------- |
88
+ | list_applications | ~170KB | ~4.4KB | **97%** |
89
+ | list_services | ~367KB | ~1.2KB | **99%** |
90
+ | list_servers | ~4KB | ~0.4KB | **90%** |
91
+ | list_application_envs | ~3KB/var | ~0.1KB/var | **97%** |
90
92
 
91
93
  ### Recommended Workflow
92
94
 
@@ -108,7 +110,7 @@ list_applications(page=2, per_page=10)
108
110
 
109
111
  ### Getting Started
110
112
 
111
- ```
113
+ ```text
112
114
  Give me an overview of my infrastructure
113
115
  Show me all my applications
114
116
  What's running on my servers?
@@ -116,9 +118,12 @@ What's running on my servers?
116
118
 
117
119
  ### Debugging & Monitoring
118
120
 
119
- ```
121
+ ```text
122
+ Diagnose my stuartmason.co.uk app
123
+ What's wrong with my-api application?
124
+ Check the status of server 192.168.1.100
125
+ Find any issues in my infrastructure
120
126
  Get the logs for application {uuid}
121
- What's the status of application {uuid}?
122
127
  What environment variables are set for application {uuid}?
123
128
  Show me recent deployments for application {uuid}
124
129
  What resources are running on server {uuid}?
@@ -126,7 +131,7 @@ What resources are running on server {uuid}?
126
131
 
127
132
  ### Application Management
128
133
 
129
- ```
134
+ ```text
130
135
  Restart application {uuid}
131
136
  Stop the database {uuid}
132
137
  Start service {uuid}
@@ -136,7 +141,7 @@ Update the DATABASE_URL env var for application {uuid}
136
141
 
137
142
  ### Project Setup
138
143
 
139
- ```
144
+ ```text
140
145
  Create a new project called "my-app"
141
146
  Create a staging environment in project {uuid}
142
147
  Deploy my app from private GitHub repo org/repo on branch main
@@ -176,6 +181,14 @@ node dist/index.js
176
181
  - `get_version` - Get Coolify API version
177
182
  - `get_infrastructure_overview` - Get a high-level overview of all infrastructure (servers, projects, applications, databases, services)
178
183
 
184
+ ### Diagnostics (Smart Lookup)
185
+
186
+ These tools accept human-friendly identifiers instead of just UUIDs:
187
+
188
+ - `diagnose_app` - Get comprehensive app diagnostics (status, logs, env vars, deployments). Accepts UUID, name, or domain (e.g., "stuartmason.co.uk" or "my-app")
189
+ - `diagnose_server` - Get server diagnostics (status, resources, domains, validation). Accepts UUID, name, or IP address (e.g., "coolify-apps" or "192.168.1.100")
190
+ - `find_issues` - Scan entire infrastructure for unhealthy apps, databases, services, and unreachable servers
191
+
179
192
  ### Servers
180
193
 
181
194
  - `list_servers` - List all servers (returns summary)
@@ -649,6 +649,34 @@ describe('CoolifyClient', () => {
649
649
  expect(result).toEqual([mockEnvVar]);
650
650
  expect(mockFetch).toHaveBeenCalledWith('http://localhost:3000/api/v1/applications/app-uuid/envs', expect.any(Object));
651
651
  });
652
+ it('should list application env vars with summary', async () => {
653
+ const fullEnvVar = {
654
+ id: 1,
655
+ uuid: 'env-var-uuid',
656
+ key: 'API_KEY',
657
+ value: 'secret123',
658
+ is_build_time: false,
659
+ is_literal: true,
660
+ is_multiline: false,
661
+ is_preview: false,
662
+ is_shared: false,
663
+ is_shown_once: false,
664
+ application_id: 1,
665
+ created_at: '2024-01-01',
666
+ updated_at: '2024-01-01',
667
+ };
668
+ mockFetch.mockResolvedValueOnce(mockResponse([fullEnvVar]));
669
+ const result = await client.listApplicationEnvVars('app-uuid', { summary: true });
670
+ // Summary should only include uuid, key, value, is_build_time
671
+ expect(result).toEqual([
672
+ {
673
+ uuid: 'env-var-uuid',
674
+ key: 'API_KEY',
675
+ value: 'secret123',
676
+ is_build_time: false,
677
+ },
678
+ ]);
679
+ });
652
680
  it('should create application env var', async () => {
653
681
  mockFetch.mockResolvedValueOnce(mockResponse({ uuid: 'new-env-uuid' }));
654
682
  const result = await client.createApplicationEnvVar('app-uuid', {
@@ -1101,4 +1129,544 @@ describe('CoolifyClient', () => {
1101
1129
  expect(result).toEqual({ message: 'Deployment cancelled' });
1102
1130
  });
1103
1131
  });
1132
+ // ===========================================================================
1133
+ // Smart Lookup Tests
1134
+ // ===========================================================================
1135
+ describe('Smart Lookup', () => {
1136
+ describe('resolveApplicationUuid', () => {
1137
+ const mockApps = [
1138
+ {
1139
+ id: 1,
1140
+ uuid: 'app-uuid-1',
1141
+ name: 'tidylinker',
1142
+ status: 'running',
1143
+ fqdn: 'https://tidylinker.com',
1144
+ created_at: '2024-01-01',
1145
+ updated_at: '2024-01-01',
1146
+ },
1147
+ {
1148
+ id: 2,
1149
+ uuid: 'app-uuid-2',
1150
+ name: 'my-api',
1151
+ status: 'running',
1152
+ fqdn: 'https://api.example.com',
1153
+ created_at: '2024-01-01',
1154
+ updated_at: '2024-01-01',
1155
+ },
1156
+ ];
1157
+ it('should return UUID directly if it looks like a UUID', async () => {
1158
+ // UUIDs are alphanumeric, 20+ chars - no API call should be made
1159
+ const result = await client.resolveApplicationUuid('xs0sgs4gog044s4k4c88kgsc');
1160
+ expect(result).toBe('xs0sgs4gog044s4k4c88kgsc');
1161
+ expect(mockFetch).not.toHaveBeenCalled();
1162
+ });
1163
+ it('should find application by name', async () => {
1164
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApps));
1165
+ const result = await client.resolveApplicationUuid('tidylinker');
1166
+ expect(result).toBe('app-uuid-1');
1167
+ });
1168
+ it('should find application by partial name (case-insensitive)', async () => {
1169
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApps));
1170
+ const result = await client.resolveApplicationUuid('TidyLink');
1171
+ expect(result).toBe('app-uuid-1');
1172
+ });
1173
+ it('should find application by domain', async () => {
1174
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApps));
1175
+ const result = await client.resolveApplicationUuid('tidylinker.com');
1176
+ expect(result).toBe('app-uuid-1');
1177
+ });
1178
+ it('should find application by partial domain', async () => {
1179
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApps));
1180
+ const result = await client.resolveApplicationUuid('api.example.com');
1181
+ expect(result).toBe('app-uuid-2');
1182
+ });
1183
+ it('should throw error if no application found', async () => {
1184
+ mockFetch.mockResolvedValueOnce(mockResponse(mockApps));
1185
+ await expect(client.resolveApplicationUuid('nonexistent')).rejects.toThrow('No application found matching "nonexistent"');
1186
+ });
1187
+ it('should throw error if multiple applications match', async () => {
1188
+ const multiMatchApps = [
1189
+ { ...mockApps[0], name: 'test-app-1' },
1190
+ { ...mockApps[1], name: 'test-app-2' },
1191
+ ];
1192
+ mockFetch.mockResolvedValueOnce(mockResponse(multiMatchApps));
1193
+ await expect(client.resolveApplicationUuid('test-app')).rejects.toThrow('Multiple applications match');
1194
+ });
1195
+ });
1196
+ describe('resolveServerUuid', () => {
1197
+ const mockServers = [
1198
+ {
1199
+ id: 1,
1200
+ uuid: 'server-uuid-1',
1201
+ name: 'coolify-apps',
1202
+ ip: '192.168.1.100',
1203
+ user: 'root',
1204
+ port: 22,
1205
+ created_at: '2024-01-01',
1206
+ updated_at: '2024-01-01',
1207
+ },
1208
+ {
1209
+ id: 2,
1210
+ uuid: 'server-uuid-2',
1211
+ name: 'production-db',
1212
+ ip: '10.0.0.50',
1213
+ user: 'root',
1214
+ port: 22,
1215
+ created_at: '2024-01-01',
1216
+ updated_at: '2024-01-01',
1217
+ },
1218
+ ];
1219
+ it('should return UUID directly if it looks like a UUID', async () => {
1220
+ const result = await client.resolveServerUuid('ggkk8w4c08gw48oowsg4g0oc');
1221
+ expect(result).toBe('ggkk8w4c08gw48oowsg4g0oc');
1222
+ expect(mockFetch).not.toHaveBeenCalled();
1223
+ });
1224
+ it('should find server by name', async () => {
1225
+ mockFetch.mockResolvedValueOnce(mockResponse(mockServers));
1226
+ const result = await client.resolveServerUuid('coolify-apps');
1227
+ expect(result).toBe('server-uuid-1');
1228
+ });
1229
+ it('should find server by partial name (case-insensitive)', async () => {
1230
+ mockFetch.mockResolvedValueOnce(mockResponse(mockServers));
1231
+ const result = await client.resolveServerUuid('Coolify');
1232
+ expect(result).toBe('server-uuid-1');
1233
+ });
1234
+ it('should find server by IP address', async () => {
1235
+ mockFetch.mockResolvedValueOnce(mockResponse(mockServers));
1236
+ const result = await client.resolveServerUuid('192.168.1.100');
1237
+ expect(result).toBe('server-uuid-1');
1238
+ });
1239
+ it('should find server by partial IP', async () => {
1240
+ mockFetch.mockResolvedValueOnce(mockResponse(mockServers));
1241
+ const result = await client.resolveServerUuid('10.0.0');
1242
+ expect(result).toBe('server-uuid-2');
1243
+ });
1244
+ it('should throw error if no server found', async () => {
1245
+ mockFetch.mockResolvedValueOnce(mockResponse(mockServers));
1246
+ await expect(client.resolveServerUuid('nonexistent')).rejects.toThrow('No server found matching "nonexistent"');
1247
+ });
1248
+ it('should throw error if multiple servers match', async () => {
1249
+ const multiMatchServers = [
1250
+ { ...mockServers[0], name: 'prod-server-1' },
1251
+ { ...mockServers[1], name: 'prod-server-2' },
1252
+ ];
1253
+ mockFetch.mockResolvedValueOnce(mockResponse(multiMatchServers));
1254
+ await expect(client.resolveServerUuid('prod-server')).rejects.toThrow('Multiple servers match');
1255
+ });
1256
+ });
1257
+ });
1258
+ // ===========================================================================
1259
+ // Diagnostic Methods Tests
1260
+ // ===========================================================================
1261
+ describe('Diagnostic Methods', () => {
1262
+ describe('diagnoseApplication', () => {
1263
+ // Use UUID-like format that matches the isLikelyUuid check
1264
+ const testAppUuid = 'app0uuid0test0001234567';
1265
+ const mockApp = {
1266
+ id: 1,
1267
+ uuid: testAppUuid,
1268
+ name: 'test-app',
1269
+ status: 'running:healthy',
1270
+ fqdn: 'https://test.com',
1271
+ git_repository: 'org/repo',
1272
+ git_branch: 'main',
1273
+ created_at: '2024-01-01',
1274
+ updated_at: '2024-01-01',
1275
+ };
1276
+ const mockLogs = 'Log line 1\nLog line 2\nLog line 3';
1277
+ const mockEnvVars = [
1278
+ {
1279
+ id: 1,
1280
+ uuid: 'env-1',
1281
+ key: 'DATABASE_URL',
1282
+ value: 'postgres://...',
1283
+ is_build_time: false,
1284
+ },
1285
+ { id: 2, uuid: 'env-2', key: 'NODE_ENV', value: 'production', is_build_time: true },
1286
+ ];
1287
+ const mockDeployments = [
1288
+ {
1289
+ id: 1,
1290
+ uuid: 'deploy-1',
1291
+ deployment_uuid: 'deploy-1',
1292
+ status: 'finished',
1293
+ force_rebuild: false,
1294
+ is_webhook: false,
1295
+ is_api: false,
1296
+ restart_only: false,
1297
+ created_at: '2024-01-01',
1298
+ updated_at: '2024-01-01',
1299
+ },
1300
+ {
1301
+ id: 2,
1302
+ uuid: 'deploy-2',
1303
+ deployment_uuid: 'deploy-2',
1304
+ status: 'finished',
1305
+ force_rebuild: false,
1306
+ is_webhook: false,
1307
+ is_api: false,
1308
+ restart_only: false,
1309
+ created_at: '2024-01-02',
1310
+ updated_at: '2024-01-02',
1311
+ },
1312
+ ];
1313
+ it('should aggregate all application data successfully', async () => {
1314
+ mockFetch
1315
+ .mockResolvedValueOnce(mockResponse(mockApp))
1316
+ .mockResolvedValueOnce(mockResponse(mockLogs))
1317
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1318
+ .mockResolvedValueOnce(mockResponse(mockDeployments));
1319
+ const result = await client.diagnoseApplication(testAppUuid);
1320
+ expect(result.application).toEqual({
1321
+ uuid: testAppUuid,
1322
+ name: 'test-app',
1323
+ status: 'running:healthy',
1324
+ fqdn: 'https://test.com',
1325
+ git_repository: 'org/repo',
1326
+ git_branch: 'main',
1327
+ });
1328
+ expect(result.health.status).toBe('healthy');
1329
+ expect(result.logs).toBe(mockLogs);
1330
+ expect(result.environment_variables.count).toBe(2);
1331
+ expect(result.environment_variables.variables).toEqual([
1332
+ { key: 'DATABASE_URL', is_build_time: false },
1333
+ { key: 'NODE_ENV', is_build_time: true },
1334
+ ]);
1335
+ expect(result.recent_deployments).toHaveLength(2);
1336
+ expect(result.errors).toBeUndefined();
1337
+ });
1338
+ it('should detect unhealthy application status', async () => {
1339
+ const unhealthyApp = { ...mockApp, status: 'exited:unhealthy' };
1340
+ mockFetch
1341
+ .mockResolvedValueOnce(mockResponse(unhealthyApp))
1342
+ .mockResolvedValueOnce(mockResponse(mockLogs))
1343
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1344
+ .mockResolvedValueOnce(mockResponse([]));
1345
+ const result = await client.diagnoseApplication(testAppUuid);
1346
+ expect(result.health.status).toBe('unhealthy');
1347
+ expect(result.health.issues).toContain('Status: exited:unhealthy');
1348
+ });
1349
+ it('should detect failed deployments as issues', async () => {
1350
+ const failedDeployments = [
1351
+ { ...mockDeployments[0], status: 'failed' },
1352
+ { ...mockDeployments[1], status: 'failed' },
1353
+ ];
1354
+ mockFetch
1355
+ .mockResolvedValueOnce(mockResponse(mockApp))
1356
+ .mockResolvedValueOnce(mockResponse(mockLogs))
1357
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1358
+ .mockResolvedValueOnce(mockResponse(failedDeployments));
1359
+ const result = await client.diagnoseApplication(testAppUuid);
1360
+ expect(result.health.issues).toContain('2 failed deployment(s) in last 5');
1361
+ });
1362
+ it('should handle partial failures gracefully', async () => {
1363
+ mockFetch
1364
+ .mockResolvedValueOnce(mockResponse(mockApp))
1365
+ .mockRejectedValueOnce(new Error('Logs unavailable'))
1366
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1367
+ .mockResolvedValueOnce(mockResponse(mockDeployments));
1368
+ const result = await client.diagnoseApplication(testAppUuid);
1369
+ expect(result.application).not.toBeNull();
1370
+ expect(result.logs).toBeNull();
1371
+ expect(result.errors).toContain('logs: Logs unavailable');
1372
+ });
1373
+ it('should handle complete failure gracefully', async () => {
1374
+ mockFetch
1375
+ .mockRejectedValueOnce(new Error('App not found'))
1376
+ .mockRejectedValueOnce(new Error('Logs unavailable'))
1377
+ .mockRejectedValueOnce(new Error('Env vars unavailable'))
1378
+ .mockRejectedValueOnce(new Error('Deployments unavailable'));
1379
+ const result = await client.diagnoseApplication(testAppUuid);
1380
+ expect(result.application).toBeNull();
1381
+ expect(result.logs).toBeNull();
1382
+ expect(result.health.status).toBe('unknown');
1383
+ expect(result.errors).toHaveLength(4);
1384
+ });
1385
+ it('should find application by name and diagnose it', async () => {
1386
+ const mockApps = [{ ...mockApp, uuid: 'found-uuid', name: 'my-app' }];
1387
+ mockFetch
1388
+ .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup
1389
+ .mockResolvedValueOnce(mockResponse(mockApp))
1390
+ .mockResolvedValueOnce(mockResponse(mockLogs))
1391
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1392
+ .mockResolvedValueOnce(mockResponse(mockDeployments));
1393
+ const result = await client.diagnoseApplication('my-app');
1394
+ expect(result.application).not.toBeNull();
1395
+ // First call should be to list apps for lookup
1396
+ expect(mockFetch).toHaveBeenNthCalledWith(1, 'http://localhost:3000/api/v1/applications', expect.any(Object));
1397
+ });
1398
+ it('should find application by domain and diagnose it', async () => {
1399
+ const mockApps = [{ ...mockApp, uuid: 'found-uuid', fqdn: 'https://tidylinker.com' }];
1400
+ mockFetch
1401
+ .mockResolvedValueOnce(mockResponse(mockApps)) // listApplications for lookup
1402
+ .mockResolvedValueOnce(mockResponse(mockApp))
1403
+ .mockResolvedValueOnce(mockResponse(mockLogs))
1404
+ .mockResolvedValueOnce(mockResponse(mockEnvVars))
1405
+ .mockResolvedValueOnce(mockResponse(mockDeployments));
1406
+ const result = await client.diagnoseApplication('tidylinker.com');
1407
+ expect(result.application).not.toBeNull();
1408
+ });
1409
+ it('should return error in result when application not found by name', async () => {
1410
+ mockFetch.mockResolvedValueOnce(mockResponse([])); // Empty app list
1411
+ const result = await client.diagnoseApplication('nonexistent-app');
1412
+ expect(result.application).toBeNull();
1413
+ expect(result.errors).toContain('No application found matching "nonexistent-app"');
1414
+ });
1415
+ });
1416
+ describe('diagnoseServer', () => {
1417
+ // Use UUID-like format that matches the isLikelyUuid check
1418
+ const testServerUuid = 'srv0uuid0test0001234567';
1419
+ const mockServer = {
1420
+ id: 1,
1421
+ uuid: testServerUuid,
1422
+ name: 'test-server',
1423
+ ip: '192.168.1.1',
1424
+ user: 'root',
1425
+ port: 22,
1426
+ status: 'running',
1427
+ is_reachable: true,
1428
+ is_usable: true,
1429
+ created_at: '2024-01-01',
1430
+ updated_at: '2024-01-01',
1431
+ };
1432
+ const mockResources = [
1433
+ {
1434
+ id: 1,
1435
+ uuid: 'res-1',
1436
+ name: 'app-1',
1437
+ type: 'application',
1438
+ status: 'running:healthy',
1439
+ created_at: '2024-01-01',
1440
+ updated_at: '2024-01-01',
1441
+ },
1442
+ {
1443
+ id: 2,
1444
+ uuid: 'res-2',
1445
+ name: 'db-1',
1446
+ type: 'database',
1447
+ status: 'running:healthy',
1448
+ created_at: '2024-01-01',
1449
+ updated_at: '2024-01-01',
1450
+ },
1451
+ ];
1452
+ const mockDomains = [{ ip: '192.168.1.1', domains: ['example.com', 'api.example.com'] }];
1453
+ const mockValidation = { message: 'Server is reachable and validated' };
1454
+ it('should aggregate all server data successfully', async () => {
1455
+ mockFetch
1456
+ .mockResolvedValueOnce(mockResponse(mockServer))
1457
+ .mockResolvedValueOnce(mockResponse(mockResources))
1458
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1459
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1460
+ const result = await client.diagnoseServer(testServerUuid);
1461
+ expect(result.server).toEqual({
1462
+ uuid: testServerUuid,
1463
+ name: 'test-server',
1464
+ ip: '192.168.1.1',
1465
+ status: 'running',
1466
+ is_reachable: true,
1467
+ });
1468
+ expect(result.health.status).toBe('healthy');
1469
+ expect(result.resources).toHaveLength(2);
1470
+ expect(result.domains).toHaveLength(1);
1471
+ expect(result.validation?.message).toBe('Server is reachable and validated');
1472
+ expect(result.errors).toBeUndefined();
1473
+ });
1474
+ it('should detect unreachable server', async () => {
1475
+ const unreachableServer = { ...mockServer, is_reachable: false };
1476
+ mockFetch
1477
+ .mockResolvedValueOnce(mockResponse(unreachableServer))
1478
+ .mockResolvedValueOnce(mockResponse(mockResources))
1479
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1480
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1481
+ const result = await client.diagnoseServer(testServerUuid);
1482
+ expect(result.health.status).toBe('unhealthy');
1483
+ expect(result.health.issues).toContain('Server is not reachable');
1484
+ });
1485
+ it('should detect unhealthy resources', async () => {
1486
+ const unhealthyResources = [
1487
+ { ...mockResources[0], status: 'exited:unhealthy' },
1488
+ { ...mockResources[1], status: 'running:healthy' },
1489
+ ];
1490
+ mockFetch
1491
+ .mockResolvedValueOnce(mockResponse(mockServer))
1492
+ .mockResolvedValueOnce(mockResponse(unhealthyResources))
1493
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1494
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1495
+ const result = await client.diagnoseServer(testServerUuid);
1496
+ expect(result.health.issues).toContain('1 unhealthy resource(s)');
1497
+ });
1498
+ it('should handle partial failures gracefully', async () => {
1499
+ mockFetch
1500
+ .mockResolvedValueOnce(mockResponse(mockServer))
1501
+ .mockRejectedValueOnce(new Error('Resources unavailable'))
1502
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1503
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1504
+ const result = await client.diagnoseServer(testServerUuid);
1505
+ expect(result.server).not.toBeNull();
1506
+ expect(result.resources).toEqual([]);
1507
+ expect(result.errors).toContain('resources: Resources unavailable');
1508
+ });
1509
+ it('should find server by name and diagnose it', async () => {
1510
+ const mockServers = [{ ...mockServer, uuid: 'found-uuid', name: 'coolify-apps' }];
1511
+ mockFetch
1512
+ .mockResolvedValueOnce(mockResponse(mockServers)) // listServers for lookup
1513
+ .mockResolvedValueOnce(mockResponse(mockServer))
1514
+ .mockResolvedValueOnce(mockResponse(mockResources))
1515
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1516
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1517
+ const result = await client.diagnoseServer('coolify-apps');
1518
+ expect(result.server).not.toBeNull();
1519
+ // First call should be to list servers for lookup
1520
+ expect(mockFetch).toHaveBeenNthCalledWith(1, 'http://localhost:3000/api/v1/servers', expect.any(Object));
1521
+ });
1522
+ it('should find server by IP and diagnose it', async () => {
1523
+ const mockServers = [{ ...mockServer, uuid: 'found-uuid', ip: '10.0.0.5' }];
1524
+ mockFetch
1525
+ .mockResolvedValueOnce(mockResponse(mockServers)) // listServers for lookup
1526
+ .mockResolvedValueOnce(mockResponse(mockServer))
1527
+ .mockResolvedValueOnce(mockResponse(mockResources))
1528
+ .mockResolvedValueOnce(mockResponse(mockDomains))
1529
+ .mockResolvedValueOnce(mockResponse(mockValidation));
1530
+ const result = await client.diagnoseServer('10.0.0.5');
1531
+ expect(result.server).not.toBeNull();
1532
+ });
1533
+ it('should return error in result when server not found by name', async () => {
1534
+ mockFetch.mockResolvedValueOnce(mockResponse([])); // Empty server list
1535
+ const result = await client.diagnoseServer('nonexistent-server');
1536
+ expect(result.server).toBeNull();
1537
+ expect(result.errors).toContain('No server found matching "nonexistent-server"');
1538
+ });
1539
+ });
1540
+ describe('findInfrastructureIssues', () => {
1541
+ const mockServers = [
1542
+ {
1543
+ id: 1,
1544
+ uuid: 'server-1',
1545
+ name: 'healthy-server',
1546
+ ip: '1.1.1.1',
1547
+ user: 'root',
1548
+ port: 22,
1549
+ is_reachable: true,
1550
+ created_at: '2024-01-01',
1551
+ updated_at: '2024-01-01',
1552
+ },
1553
+ {
1554
+ id: 2,
1555
+ uuid: 'server-2',
1556
+ name: 'unreachable-server',
1557
+ ip: '2.2.2.2',
1558
+ user: 'root',
1559
+ port: 22,
1560
+ is_reachable: false,
1561
+ status: 'error',
1562
+ created_at: '2024-01-01',
1563
+ updated_at: '2024-01-01',
1564
+ },
1565
+ ];
1566
+ const mockApplications = [
1567
+ {
1568
+ id: 1,
1569
+ uuid: 'app-1',
1570
+ name: 'healthy-app',
1571
+ status: 'running:healthy',
1572
+ created_at: '2024-01-01',
1573
+ updated_at: '2024-01-01',
1574
+ },
1575
+ {
1576
+ id: 2,
1577
+ uuid: 'app-2',
1578
+ name: 'unhealthy-app',
1579
+ status: 'exited:unhealthy',
1580
+ created_at: '2024-01-01',
1581
+ updated_at: '2024-01-01',
1582
+ },
1583
+ ];
1584
+ const mockDatabases = [
1585
+ {
1586
+ id: 1,
1587
+ uuid: 'db-1',
1588
+ name: 'healthy-db',
1589
+ type: 'postgresql',
1590
+ status: 'running:healthy',
1591
+ is_public: false,
1592
+ image: 'postgres:16',
1593
+ created_at: '2024-01-01',
1594
+ updated_at: '2024-01-01',
1595
+ },
1596
+ {
1597
+ id: 2,
1598
+ uuid: 'db-2',
1599
+ name: 'stopped-db',
1600
+ type: 'redis',
1601
+ status: 'exited:unhealthy',
1602
+ is_public: false,
1603
+ image: 'redis:7',
1604
+ created_at: '2024-01-01',
1605
+ updated_at: '2024-01-01',
1606
+ },
1607
+ ];
1608
+ const mockServices = [
1609
+ {
1610
+ id: 1,
1611
+ uuid: 'svc-1',
1612
+ name: 'healthy-service',
1613
+ type: 'pocketbase',
1614
+ status: 'running:healthy',
1615
+ created_at: '2024-01-01',
1616
+ updated_at: '2024-01-01',
1617
+ },
1618
+ {
1619
+ id: 2,
1620
+ uuid: 'svc-2',
1621
+ name: 'exited-service',
1622
+ type: 'n8n',
1623
+ status: 'exited',
1624
+ created_at: '2024-01-01',
1625
+ updated_at: '2024-01-01',
1626
+ },
1627
+ ];
1628
+ it('should find all infrastructure issues', async () => {
1629
+ mockFetch
1630
+ .mockResolvedValueOnce(mockResponse(mockServers))
1631
+ .mockResolvedValueOnce(mockResponse(mockApplications))
1632
+ .mockResolvedValueOnce(mockResponse(mockDatabases))
1633
+ .mockResolvedValueOnce(mockResponse(mockServices));
1634
+ const result = await client.findInfrastructureIssues();
1635
+ expect(result.summary.total_issues).toBe(4);
1636
+ expect(result.summary.unreachable_servers).toBe(1);
1637
+ expect(result.summary.unhealthy_applications).toBe(1);
1638
+ expect(result.summary.unhealthy_databases).toBe(1);
1639
+ expect(result.summary.unhealthy_services).toBe(1);
1640
+ expect(result.issues).toHaveLength(4);
1641
+ expect(result.errors).toBeUndefined();
1642
+ });
1643
+ it('should return empty issues when everything is healthy', async () => {
1644
+ const healthyServers = [mockServers[0]];
1645
+ const healthyApps = [mockApplications[0]];
1646
+ const healthyDbs = [mockDatabases[0]];
1647
+ const healthySvcs = [mockServices[0]];
1648
+ mockFetch
1649
+ .mockResolvedValueOnce(mockResponse(healthyServers))
1650
+ .mockResolvedValueOnce(mockResponse(healthyApps))
1651
+ .mockResolvedValueOnce(mockResponse(healthyDbs))
1652
+ .mockResolvedValueOnce(mockResponse(healthySvcs));
1653
+ const result = await client.findInfrastructureIssues();
1654
+ expect(result.summary.total_issues).toBe(0);
1655
+ expect(result.issues).toHaveLength(0);
1656
+ });
1657
+ it('should handle partial failures and still report issues', async () => {
1658
+ mockFetch
1659
+ .mockResolvedValueOnce(mockResponse(mockServers))
1660
+ .mockRejectedValueOnce(new Error('Applications unavailable'))
1661
+ .mockResolvedValueOnce(mockResponse(mockDatabases))
1662
+ .mockResolvedValueOnce(mockResponse(mockServices));
1663
+ const result = await client.findInfrastructureIssues();
1664
+ expect(result.summary.unreachable_servers).toBe(1);
1665
+ expect(result.summary.unhealthy_databases).toBe(1);
1666
+ expect(result.summary.unhealthy_services).toBe(1);
1667
+ expect(result.summary.unhealthy_applications).toBe(0); // Failed to fetch
1668
+ expect(result.errors).toContain('applications: Applications unavailable');
1669
+ });
1670
+ });
1671
+ });
1104
1672
  });