feat(16-02): replace start/stop/restart with GraphQL mutations

- Add Build Start/Stop Mutation Code nodes to construct GraphQL queries
- Update Start/Stop Container HTTP nodes to POST to Unraid GraphQL API
- Add GraphQL Error Handler nodes after each mutation (maps ALREADY_IN_STATE to 304)
- Implement restart as sequential stop+start chain (no native restart mutation)
- Add Handle Stop-for-Restart Result node to tolerate 304 on stop step
- Wire: Route Action -> Build Mutation -> HTTP -> Error Handler -> Format Result
- Format Result nodes unchanged (zero-change migration for output formatting)
- Workflow pushed to n8n: HTTP 200
This commit is contained in:
Lucas Berger
2026-02-09 10:23:37 -05:00
parent 6caa0f171f
commit a1c0ce25cc
+282 -21
View File
@@ -216,7 +216,23 @@
{
"parameters": {
"method": "POST",
"url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $json.containerId }}/start",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: $json.query}) }}",
"options": {
"timeout": 15000
}
@@ -234,7 +250,23 @@
{
"parameters": {
"method": "POST",
"url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $json.containerId }}/stop?t=10",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: $json.query}) }}",
"options": {
"timeout": 15000
}
@@ -252,13 +284,29 @@
{
"parameters": {
"method": "POST",
"url": "=http://docker-socket-proxy:2375/v1.47/containers/{{ $json.containerId }}/restart?t=10",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: $json.query}) }}",
"options": {
"timeout": 15000
}
},
"id": "http-restart-container",
"name": "Restart Container",
"id": "http-stop-for-restart",
"name": "Stop For Restart",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
@@ -332,6 +380,131 @@
720,
400
]
},
{
"parameters": {
"jsCode": "// Build Start Mutation\nconst data = $('Route Action').item.json;\nconst unraidId = data.unraidId || data.containerId;\nreturn { json: { query: `mutation { docker { start(id: \"${unraidId}\") { id state } } }` } };"
},
"id": "code-build-start-mutation",
"name": "Build Start Mutation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1080,
200
]
},
{
"parameters": {
"jsCode": "// GraphQL Error Handler - Standardized error checking and HTTP status mapping\n// Input: $input.item.json = raw response from HTTP Request node\n// Output: { success, statusCode, alreadyInState, message, data }\n\nconst response = $input.item.json;\n\n// Check GraphQL errors array\nif (response.errors && response.errors.length > 0) {\n const error = response.errors[0];\n const code = error.extensions?.code;\n const message = error.message;\n \n // Map error codes to HTTP equivalents\n if (code === 'ALREADY_IN_STATE') {\n // Maps to Docker API HTTP 304 pattern (used in n8n-actions.json)\n return {\n json: {\n success: true,\n statusCode: 304,\n alreadyInState: true,\n message: 'Container already in desired state'\n }\n };\n }\n \n // Error codes that should throw\n if (code === 'NOT_FOUND') {\n return {\n json: {\n success: false,\n statusCode: 404,\n message: `Container not found: ${message}`\n }\n };\n }\n \n if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {\n return {\n json: {\n success: false,\n statusCode: 403,\n message: `Permission denied: ${message}`\n }\n };\n }\n \n // Any other GraphQL error\n return {\n json: {\n success: false,\n statusCode: 500,\n message: `Unraid API error: ${message}`\n }\n };\n}\n\n// Check HTTP-level errors\nif (response.statusCode >= 400) {\n return {\n json: {\n success: false,\n statusCode: response.statusCode,\n message: `HTTP ${response.statusCode}: ${response.statusMessage}`\n }\n };\n}\n\n// Check missing data field\nif (!response.data) {\n return {\n json: {\n success: false,\n statusCode: 500,\n message: 'GraphQL response missing data field'\n }\n };\n}\n\n// Success\nreturn {\n json: {\n success: true,\n statusCode: 200,\n alreadyInState: false,\n data: response.data\n }\n};\n"
},
"id": "code-start-error-handler",
"name": "Start Error Handler",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1280,
200
]
},
{
"parameters": {
"jsCode": "// Build Stop Mutation\nconst data = $('Route Action').item.json;\nconst unraidId = data.unraidId || data.containerId;\nreturn { json: { query: `mutation { docker { stop(id: \"${unraidId}\") { id state } } }` } };"
},
"id": "code-build-stop-mutation",
"name": "Build Stop Mutation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1080,
300
]
},
{
"parameters": {
"jsCode": "// GraphQL Error Handler - Standardized error checking and HTTP status mapping\n// Input: $input.item.json = raw response from HTTP Request node\n// Output: { success, statusCode, alreadyInState, message, data }\n\nconst response = $input.item.json;\n\n// Check GraphQL errors array\nif (response.errors && response.errors.length > 0) {\n const error = response.errors[0];\n const code = error.extensions?.code;\n const message = error.message;\n \n // Map error codes to HTTP equivalents\n if (code === 'ALREADY_IN_STATE') {\n // Maps to Docker API HTTP 304 pattern (used in n8n-actions.json)\n return {\n json: {\n success: true,\n statusCode: 304,\n alreadyInState: true,\n message: 'Container already in desired state'\n }\n };\n }\n \n // Error codes that should throw\n if (code === 'NOT_FOUND') {\n return {\n json: {\n success: false,\n statusCode: 404,\n message: `Container not found: ${message}`\n }\n };\n }\n \n if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {\n return {\n json: {\n success: false,\n statusCode: 403,\n message: `Permission denied: ${message}`\n }\n };\n }\n \n // Any other GraphQL error\n return {\n json: {\n success: false,\n statusCode: 500,\n message: `Unraid API error: ${message}`\n }\n };\n}\n\n// Check HTTP-level errors\nif (response.statusCode >= 400) {\n return {\n json: {\n success: false,\n statusCode: response.statusCode,\n message: `HTTP ${response.statusCode}: ${response.statusMessage}`\n }\n };\n}\n\n// Check missing data field\nif (!response.data) {\n return {\n json: {\n success: false,\n statusCode: 500,\n message: 'GraphQL response missing data field'\n }\n };\n}\n\n// Success\nreturn {\n json: {\n success: true,\n statusCode: 200,\n alreadyInState: false,\n data: response.data\n }\n};\n"
},
"id": "code-stop-error-handler",
"name": "Stop Error Handler",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1280,
300
]
},
{
"parameters": {
"jsCode": "// Build Stop-for-Restart Mutation\nconst data = $('Route Action').item.json;\nconst unraidId = data.unraidId || data.containerId;\nreturn { json: { query: `mutation { docker { stop(id: \"${unraidId}\") { id state } } }`, unraidId } };"
},
"id": "code-build-restart-stop-mutation",
"name": "Build Stop-for-Restart Mutation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1080,
400
]
},
{
"parameters": {
"jsCode": "// Handle Stop-for-Restart Result\n// Check response: if success OR statusCode 304 (already stopped) -> proceed to start\n// If error -> fail restart\n\nconst response = $input.item.json;\nconst prevData = $('Build Stop-for-Restart Mutation').item.json;\n\n// Check for errors\nif (response.errors && response.errors.length > 0) {\n const error = response.errors[0];\n const code = error.extensions?.code;\n \n // ALREADY_IN_STATE (304) is OK - container already stopped\n if (code === 'ALREADY_IN_STATE') {\n // Continue to start step\n return { json: { query: `mutation { docker { start(id: \"${prevData.unraidId}\") { id state } } }` } };\n }\n \n // Any other error - fail restart\n return {\n json: {\n error: true,\n statusCode: 500,\n message: `Failed to stop container for restart: ${error.message}`\n }\n };\n}\n\n// Check HTTP-level errors\nif (response.statusCode && response.statusCode >= 400) {\n return {\n json: {\n error: true,\n statusCode: response.statusCode,\n message: 'Failed to stop container for restart'\n }\n };\n}\n\n// Success - proceed to start\nreturn { json: { query: `mutation { docker { start(id: \"${prevData.unraidId}\") { id state } } }` } };\n"
},
"id": "code-handle-stop-for-restart",
"name": "Handle Stop-for-Restart Result",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1280,
400
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
},
{
"name": "x-api-key",
"value": "={{ $env.UNRAID_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({query: $json.query}) }}",
"options": {
"timeout": 15000
}
},
"id": "http-start-after-stop",
"name": "Start After Stop",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1480,
400
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// GraphQL Error Handler for Restart (after Start step)\n// Input: $input.item.json = raw response from Start After Stop\n// Output: { success, statusCode, alreadyInState, message, data }\n\nconst response = $input.item.json;\n\n// Check GraphQL errors array\nif (response.errors && response.errors.length > 0) {\n const error = response.errors[0];\n const code = error.extensions?.code;\n const message = error.message;\n \n // Map error codes to HTTP equivalents\n if (code === 'ALREADY_IN_STATE') {\n // Maps to Docker API HTTP 304 pattern (container already running)\n return {\n json: {\n success: true,\n statusCode: 304,\n alreadyInState: true,\n message: 'Container already in desired state'\n }\n };\n }\n \n // Error codes that should throw\n if (code === 'NOT_FOUND') {\n return {\n json: {\n success: false,\n statusCode: 404,\n message: `Container not found: ${message}`\n }\n };\n }\n \n if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {\n return {\n json: {\n success: false,\n statusCode: 403,\n message: `Permission denied: ${message}`\n }\n };\n }\n \n // Any other GraphQL error\n return {\n json: {\n success: false,\n statusCode: 500,\n message: `Unraid API error: ${message}`\n }\n };\n}\n\n// Check HTTP-level errors\nif (response.statusCode >= 400) {\n return {\n json: {\n success: false,\n statusCode: response.statusCode,\n message: `HTTP ${response.statusCode}: ${response.statusMessage}`\n }\n };\n}\n\n// Check missing data field\nif (!response.data) {\n return {\n json: {\n success: false,\n statusCode: 500,\n message: 'GraphQL response missing data field'\n }\n };\n}\n\n// Success\nreturn {\n json: {\n success: true,\n statusCode: 200,\n alreadyInState: false,\n data: response.data\n }\n};\n"
},
"id": "code-restart-error-handler",
"name": "Restart Error Handler",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1680,
400
]
}
],
"connections": {
@@ -379,21 +552,21 @@
"main": [
[
{
"node": "Start Container",
"node": "Build Start Mutation",
"type": "main",
"index": 0
}
],
[
{
"node": "Stop Container",
"node": "Build Stop Mutation",
"type": "main",
"index": 0
}
],
[
{
"node": "Restart Container",
"node": "Build Stop-for-Restart Mutation",
"type": "main",
"index": 0
}
@@ -404,7 +577,7 @@
"main": [
[
{
"node": "Format Start Result",
"node": "Start Error Handler",
"type": "main",
"index": 0
}
@@ -415,18 +588,7 @@
"main": [
[
{
"node": "Format Stop Result",
"type": "main",
"index": 0
}
]
]
},
"Restart Container": {
"main": [
[
{
"node": "Format Restart Result",
"node": "Stop Error Handler",
"type": "main",
"index": 0
}
@@ -465,6 +627,105 @@
}
]
]
},
"Build Start Mutation": {
"main": [
[
{
"node": "Start Container",
"type": "main",
"index": 0
}
]
]
},
"Start Error Handler": {
"main": [
[
{
"node": "Format Start Result",
"type": "main",
"index": 0
}
]
]
},
"Build Stop Mutation": {
"main": [
[
{
"node": "Stop Container",
"type": "main",
"index": 0
}
]
]
},
"Stop Error Handler": {
"main": [
[
{
"node": "Format Stop Result",
"type": "main",
"index": 0
}
]
]
},
"Build Stop-for-Restart Mutation": {
"main": [
[
{
"node": "Stop For Restart",
"type": "main",
"index": 0
}
]
]
},
"Stop For Restart": {
"main": [
[
{
"node": "Handle Stop-for-Restart Result",
"type": "main",
"index": 0
}
]
]
},
"Handle Stop-for-Restart Result": {
"main": [
[
{
"node": "Start After Stop",
"type": "main",
"index": 0
}
]
]
},
"Start After Stop": {
"main": [
[
{
"node": "Restart Error Handler",
"type": "main",
"index": 0
}
]
]
},
"Restart Error Handler": {
"main": [
[
{
"node": "Format Restart Result",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {