diff --git a/n8n-actions.json b/n8n-actions.json index 313358d..86d9afc 100644 --- a/n8n-actions.json +++ b/n8n-actions.json @@ -80,23 +80,41 @@ }, { "parameters": { - "url": "http://docker-socket-proxy:2375/v1.47/containers/json?all=true", + "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": "={\"query\": \"query { docker { containers { id names state image } } }\"}", "options": { - "timeout": 5000 + "timeout": 15000 } }, "id": "http-get-containers", - "name": "Get All Containers", + "name": "Query All Containers", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 600, 400 - ] + ], + "onError": "continueRegularOutput" }, { "parameters": { - "jsCode": "// Find container by name and resolve ID\nconst triggerData = $('When executed by another workflow').item.json;\nconst containerName = triggerData.containerName;\nconst containers = $input.all();\n\n// Normalize function to strip leading slash\nconst normalizeName = (name) => name.replace(/^\\//, '').toLowerCase();\nconst searchName = normalizeName(containerName);\n\n// Find matching container\nlet matched = null;\nfor (const item of containers) {\n const c = item.json;\n if (c.Names && c.Names.length > 0) {\n const cName = normalizeName(c.Names[0]);\n if (cName === searchName || cName.includes(searchName)) {\n matched = c;\n break;\n }\n }\n}\n\nif (!matched) {\n // Return error - container not found\n return {\n json: {\n ...triggerData,\n containerId: '',\n error: `Container '${containerName}' not found`\n }\n };\n}\n\nreturn {\n json: {\n ...triggerData,\n containerId: matched.Id\n }\n};" + "jsCode": "// Find container by name and resolve ID\nconst triggerData = $('When executed by another workflow').item.json;\nconst containerName = triggerData.containerName;\nconst containers = $input.all();\n\n// Normalize function to strip leading slash\nconst normalizeName = (name) => name.replace(/^\\//, '').toLowerCase();\nconst searchName = normalizeName(containerName);\n\n// Find matching container\nlet matched = null;\nfor (const item of containers) {\n const c = item.json;\n if (c.Names && c.Names.length > 0) {\n const cName = normalizeName(c.Names[0]);\n if (cName === searchName || cName.includes(searchName)) {\n matched = c;\n break;\n }\n }\n}\n\nif (!matched) {\n // Return error - container not found\n return {\n json: {\n ...triggerData,\n containerId: '',\n error: `Container '${containerName}' not found`\n }\n };\n}\n\nreturn {\n json: {\n ...triggerData,\n containerId: matched.Id,\n unraidId: matched.Id // Add PrefixedID for downstream mutations\n }\n};" }, "id": "code-resolve-id", "name": "Resolve Container ID", @@ -287,6 +305,33 @@ 1380, 400 ] + }, + { + "parameters": { + "jsCode": "// GraphQL Response Normalizer - Transform Unraid GraphQL to Docker API contract\n// Input: $input.item.json = raw GraphQL response\n// Output: Array of normalized containers\n\nconst response = $input.item.json;\n\n// Validation: Check for GraphQL errors\nif (response.errors && response.errors.length > 0) {\n const messages = response.errors.map(e => e.message).join('; ');\n throw new Error(`GraphQL error: ${messages}`);\n}\n\n// Validation: Check response structure\nif (!response.data?.docker?.containers) {\n throw new Error('Invalid GraphQL response structure: missing data.docker.containers');\n}\n\n// State mapping: Unraid UPPERCASE -> Docker lowercase\nconst stateMap = {\n 'RUNNING': 'running',\n 'STOPPED': 'exited', // Docker convention: stopped = exited\n 'PAUSED': 'paused'\n};\n\nfunction normalizeState(unraidState) {\n return stateMap[unraidState] || unraidState.toLowerCase();\n}\n\n// Transform each container\nconst containers = response.data.docker.containers;\nconst normalized = containers.map(container => {\n const dockerState = normalizeState(container.state);\n \n return {\n // Core fields matching Docker API contract\n Id: container.id, // Keep full PrefixedID (registry handles translation)\n Names: container.names, // Already has '/' prefix (Phase 14 verified)\n State: dockerState, // Normalized lowercase state\n Status: dockerState, // Docker has separate Status field\n Image: '', // Not available in basic query\n \n // Debug field: preserve original Unraid ID\n _unraidId: container.id\n };\n});\n\n// Return as array of items (n8n multi-item output format)\nreturn normalized.map(container => ({ json: container }));\n" + }, + "id": "code-graphql-normalizer", + "name": "GraphQL Response Normalizer", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 660, + 400 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "jsCode": "// Update Container ID Registry with fresh container data\n// Input: Array of containers from Normalizer\n// Output: Pass through all containers unchanged\n\nconst containers = $input.all().map(item => item.json);\n\n// Get static data registry\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\n\nconst containerMap = {};\n\nfor (const container of containers) {\n // Extract container name: strip leading '/', lowercase\n const rawName = container.Names[0];\n const name = rawName.startsWith('/') ? rawName.substring(1).toLowerCase() : rawName.toLowerCase();\n \n // Map name -> {name, unraidId}\n // The container.Id field IS the PrefixedID (129-char format)\n containerMap[name] = {\n name: name,\n unraidId: container.Id\n };\n}\n\n// Store timestamp\nregistry._lastRefresh = Date.now();\n\n// Serialize (top-level assignment - this is what n8n persists)\nregistry._containerIdMap = JSON.stringify(containerMap);\n\n// Pass through all containers unchanged (multi-item output)\nreturn containers.map(c => ({ json: c }));\n" + }, + "id": "code-update-registry", + "name": "Update Container ID Registry", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 720, + 400 + ] } ], "connections": { @@ -319,17 +364,6 @@ ] ] }, - "Get All Containers": { - "main": [ - [ - { - "node": "Resolve Container ID", - "type": "main", - "index": 0 - } - ] - ] - }, "Resolve Container ID": { "main": [ [ @@ -398,6 +432,39 @@ } ] ] + }, + "Query All Containers": { + "main": [ + [ + { + "node": "GraphQL Response Normalizer", + "type": "main", + "index": 0 + } + ] + ] + }, + "GraphQL Response Normalizer": { + "main": [ + [ + { + "node": "Update Container ID Registry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update Container ID Registry": { + "main": [ + [ + { + "node": "Resolve Container ID", + "type": "main", + "index": 0 + } + ] + ] } }, "settings": {