Files
unraid-docker-manager/n8n-batch-ui.json
Lucas Berger 216f3a406a fix(16): repair broken connections, auth credentials, and dead code across 4 workflows
Phase 16 plans 16-02 through 16-05 introduced three classes of defects:

1. Connection keys used node IDs instead of node names (33 broken links
   across n8n-workflow.json, n8n-batch-ui.json, n8n-actions.json)
2. GraphQL HTTP nodes used $env.UNRAID_API_KEY manual headers instead of
   Header Auth credential, causing CSRF/UNAUTHENTICATED errors (20 nodes)
3. Duplicate node name "Execute Batch Update" (serial vs parallel paths)

Also fixes Build Cancel Return Submenu using $input.item.json instead of
$('Prepare Cancel From Confirm').item.json after GraphQL query chain.

Removes 12 dead/orphan nodes (6 pre-migration dead code chains,
6 unused utility templates). Node count: 193 -> 181.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 11:29:40 -05:00

861 lines
50 KiB
JSON

{
"name": "Batch UI",
"nodes": [
{
"parameters": {
"inputSource": "passthrough",
"schema": {
"schemaType": "fromFields",
"fields": [
{
"fieldName": "chatId",
"fieldType": "number"
},
{
"fieldName": "messageId",
"fieldType": "number"
},
{
"fieldName": "callbackData",
"fieldType": "string"
},
{
"fieldName": "queryId",
"fieldType": "string"
},
{
"fieldName": "action",
"fieldType": "string"
},
{
"fieldName": "batchPage",
"fieldType": "number"
},
{
"fieldName": "bitmap",
"fieldType": "string"
},
{
"fieldName": "containerIndex",
"fieldType": "number"
},
{
"fieldName": "batchAction",
"fieldType": "string"
},
{
"fieldName": "correlationId",
"fieldType": "string"
}
]
}
},
"id": "batch-ui-trigger",
"name": "When executed by another workflow",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
240,
300
]
},
{
"parameters": {
"rules": {
"values": [
{
"id": "route-mode",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-mode",
"leftValue": "={{ $json.action }}",
"rightValue": "mode",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "mode"
},
{
"id": "route-toggle",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-toggle",
"leftValue": "={{ $json.action }}",
"rightValue": "toggle",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "toggle"
},
{
"id": "route-nav",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-nav",
"leftValue": "={{ $json.action }}",
"rightValue": "nav",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "nav"
},
{
"id": "route-exec",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-exec",
"leftValue": "={{ $json.action }}",
"rightValue": "exec",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "exec"
},
{
"id": "route-clear",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-clear",
"leftValue": "={{ $json.action }}",
"rightValue": "clear",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "clear"
},
{
"id": "route-cancel",
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "loose"
},
"conditions": [
{
"id": "is-cancel",
"leftValue": "={{ $json.action }}",
"rightValue": "cancel",
"operator": {
"type": "string",
"operation": "equals"
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "cancel"
}
]
},
"options": {
"fallbackOutput": "none"
}
},
"id": "switch-route-batch-action",
"name": "Route Batch UI Action",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\": \"query { docker { containers { id names state image } } }\"}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"errorHandling": "continueRegularOutput"
}
}
},
"genericAuthType": "httpHeaderAuth"
},
"id": "http-fetch-containers-mode",
"name": "Fetch Containers For Mode",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
100
],
"credentials": {
"httpHeaderAuth": {
"id": "unraid-api-key-credential-id",
"name": "Unraid API Key"
}
}
},
{
"parameters": {
"jsCode": "// Build batch selection keyboard with bitmap-encoded selection state\n// Bitmap helpers\nfunction encodeBitmap(selectedIndices) {\n let bitmap = 0n;\n for (const idx of selectedIndices) {\n bitmap |= (1n << BigInt(idx));\n }\n return bitmap.toString(36);\n}\n\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nconst containers = $input.all();\nconst triggerData = $('When executed by another workflow').item.json;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\nconst page = triggerData.batchPage || 0;\nconst bitmap = triggerData.bitmap || '0';\n\n// Decode bitmap to get selected indices\nconst selectedIndices = decodeBitmap(bitmap);\nconst selectedCount = selectedIndices.size;\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination: 6 containers per page\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard with checkmarks\nconst keyboard = displayContainers.map((c, localIdx) => {\n const globalIdx = start + localIdx;\n const isSelected = selectedIndices.has(globalIdx);\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const checkmark = isSelected ? '\\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `b:${page}:${bitmap}:${globalIdx}`\n }\n ];\n});\n\n// Add navigation row if needed\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `bn:${bitmap}:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `bn:${bitmap}:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Start (${selectedCount})`, callback_data: `be:start:${bitmap}` },\n { text: `Stop (${selectedCount})`, callback_data: `be:stop:${bitmap}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst totalCount = sortedContainers.length;\nlet message = selectedCount > 0 \n ? `<b>Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}</b>\\n(Tap to select/deselect)`\n : '<b>Select containers for batch action:</b>\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n success: true,\n action: 'keyboard',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n text: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n bitmap: bitmap,\n selectedCount: selectedCount,\n answerText: selectedCount > 0 ? `${selectedCount} selected` : 'Select containers...'\n }\n};"
},
"id": "code-build-batch-keyboard",
"name": "Build Batch Keyboard",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
100
]
},
{
"parameters": {
"jsCode": "// Handle container toggle in batch selection using bitmap encoding\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nfunction encodeBitmap(selectedIndices) {\n let bitmap = 0n;\n for (const idx of selectedIndices) {\n bitmap |= (1n << BigInt(idx));\n }\n return bitmap.toString(36);\n}\n\nconst triggerData = $('When executed by another workflow').item.json;\nconst bitmap = triggerData.bitmap || '0';\nconst containerIndex = triggerData.containerIndex || 0;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\nconst page = triggerData.batchPage || 0;\n\n// Decode current selection\nconst selectedIndices = decodeBitmap(bitmap);\n\n// Toggle the container index\nif (selectedIndices.has(containerIndex)) {\n selectedIndices.delete(containerIndex);\n} else {\n selectedIndices.add(containerIndex);\n}\n\n// Encode back to bitmap\nconst newBitmap = encodeBitmap(selectedIndices);\n\nreturn {\n json: {\n success: true,\n action: 'toggle_update',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: page,\n bitmap: newBitmap,\n selectedCount: selectedIndices.size,\n needsKeyboardUpdate: true\n }\n};"
},
"id": "code-handle-toggle",
"name": "Handle Toggle",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
200
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"typeValidation": "strict"
},
"conditions": [
{
"id": "needs-update",
"leftValue": "={{ $json.needsKeyboardUpdate }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "if-needs-keyboard-update",
"name": "Needs Keyboard Update?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.1,
"position": [
900,
200
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\": \"query { docker { containers { id names state image } } }\"}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"errorHandling": "continueRegularOutput"
}
}
},
"genericAuthType": "httpHeaderAuth"
},
"id": "http-fetch-containers-toggle",
"name": "Fetch Containers For Update",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1120,
100
],
"credentials": {
"httpHeaderAuth": {
"id": "unraid-api-key-credential-id",
"name": "Unraid API Key"
}
}
},
{
"parameters": {
"jsCode": "// Rebuild batch selection keyboard after toggle with bitmap encoding\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nconst containers = $input.all();\nconst toggleData = $('Handle Toggle').item.json;\nconst bitmap = toggleData.bitmap || '0';\nconst selectedCount = toggleData.selectedCount || 0;\nconst chatId = toggleData.chatId;\nconst messageId = toggleData.messageId;\nconst queryId = toggleData.queryId;\nconst page = toggleData.batchPage || 0;\n\n// Decode bitmap to get selected indices\nconst selectedIndices = decodeBitmap(bitmap);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard with checkmarks\nconst keyboard = displayContainers.map((c, localIdx) => {\n const globalIdx = start + localIdx;\n const isSelected = selectedIndices.has(globalIdx);\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const checkmark = isSelected ? '\\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `b:${page}:${bitmap}:${globalIdx}`\n }\n ];\n});\n\n// Add navigation row\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `bn:${bitmap}:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `bn:${bitmap}:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Start (${selectedCount})`, callback_data: `be:start:${bitmap}` },\n { text: `Stop (${selectedCount})`, callback_data: `be:stop:${bitmap}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst totalCount = sortedContainers.length;\nlet message = selectedCount > 0 \n ? `<b>Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}</b>\\n(Tap to select/deselect)`\n : '<b>Select containers for batch action:</b>\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n success: true,\n action: 'keyboard',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n text: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n bitmap: bitmap,\n answerText: ''\n }\n};"
},
"id": "code-rebuild-keyboard",
"name": "Rebuild Keyboard After Toggle",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1340,
100
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\": \"query { docker { containers { id names state image } } }\"}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"errorHandling": "continueRegularOutput"
}
}
},
"genericAuthType": "httpHeaderAuth"
},
"id": "http-fetch-containers-exec",
"name": "Fetch Containers For Exec",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
680,
400
],
"credentials": {
"httpHeaderAuth": {
"id": "unraid-api-key-credential-id",
"name": "Unraid API Key"
}
}
},
{
"parameters": {
"jsCode": "// Handle batch:exec callback with bitmap - resolve bitmap to names and determine if confirmation needed\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nconst triggerData = $('When executed by another workflow').item.json;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\n\n// Get batch action and bitmap from trigger data\nconst batchAction = triggerData.batchAction || 'start';\nconst bitmap = triggerData.bitmap || '0';\n\n// Decode bitmap to indices\nconst selectedIndices = decodeBitmap(bitmap);\nconst count = selectedIndices.size;\n\n// Get containers from HTTP request\nconst containers = $input.all();\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically (same as keyboard build)\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Map indices to container names\nconst containerNames = Array.from(selectedIndices)\n .filter(i => i < sortedContainers.length)\n .map(i => sortedContainers[i].name);\n\n// Check if stop action (needs confirmation per CONTEXT)\nconst needsConfirmation = batchAction === 'stop';\n\nif (needsConfirmation) {\n // Build stop confirmation keyboard with bitmap\n const timestamp = Math.floor(Date.now() / 1000);\n const message = `Stop ${count} container${count !== 1 ? 's' : ''}?\\n\\n${containerNames.map(n => '\\u2022 ' + n).join('\\n')}`;\n \n return {\n json: {\n success: true,\n action: 'confirmation',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n text: message,\n keyboard: {\n inline_keyboard: [\n [\n { text: '\\u2705 Confirm Stop', callback_data: `bstop:confirm:${bitmap}:${timestamp}:kb` },\n { text: '\\u274c Cancel', callback_data: 'batch:cancel' }\n ]\n ]\n },\n answerText: 'Confirm stop...'\n }\n };\n}\n\n// Immediate execution (non-stop actions)\nreturn {\n json: {\n success: true,\n action: 'execute',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchAction: batchAction,\n containerNames: containerNames,\n bitmap: bitmap,\n count: count,\n fromKeyboard: true,\n answerText: `Starting batch ${batchAction}...`\n }\n};"
},
"id": "code-handle-exec",
"name": "Handle Exec",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
900,
400
]
},
{
"parameters": {
"jsCode": "// Handle batch:nav callback - prepare for keyboard rebuild with new page\nconst triggerData = $('When executed by another workflow').item.json;\nconst page = triggerData.batchPage || 0;\nconst bitmap = triggerData.bitmap || '0';\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\n\n// Decode bitmap to count selected\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nconst selectedIndices = decodeBitmap(bitmap);\n\nreturn {\n json: {\n success: true,\n action: 'nav_update',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: page,\n bitmap: bitmap,\n selectedCount: selectedIndices.size,\n needsKeyboardUpdate: true\n }\n};"
},
"id": "code-handle-nav",
"name": "Handle Nav",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
300
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\": \"query { docker { containers { id names state image } } }\"}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"errorHandling": "continueRegularOutput"
}
}
},
"genericAuthType": "httpHeaderAuth"
},
"id": "http-fetch-containers-nav",
"name": "Fetch Containers For Nav",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
300
],
"credentials": {
"httpHeaderAuth": {
"id": "unraid-api-key-credential-id",
"name": "Unraid API Key"
}
}
},
{
"parameters": {
"jsCode": "// Rebuild batch selection keyboard for navigation with bitmap encoding\nfunction decodeBitmap(b36) {\n if (!b36 || b36 === '0') return new Set();\n let val = 0n;\n for (const ch of b36) {\n val = val * 36n + BigInt(parseInt(ch, 36));\n }\n const indices = new Set();\n let i = 0;\n let v = val;\n while (v > 0n) {\n if (v & 1n) indices.add(i);\n v >>= 1n;\n i++;\n }\n return indices;\n}\n\nconst containers = $input.all();\nconst navData = $('Handle Nav').item.json;\nconst bitmap = navData.bitmap || '0';\nconst selectedCount = navData.selectedCount || 0;\nconst chatId = navData.chatId;\nconst messageId = navData.messageId;\nconst queryId = navData.queryId;\nconst page = navData.batchPage || 0;\n\n// Decode bitmap to get selected indices\nconst selectedIndices = decodeBitmap(bitmap);\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard with checkmarks\nconst keyboard = displayContainers.map((c, localIdx) => {\n const globalIdx = start + localIdx;\n const isSelected = selectedIndices.has(globalIdx);\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const checkmark = isSelected ? '\\u2713 ' : '';\n return [\n {\n text: `${checkmark}${icon} ${c.name}`,\n callback_data: `b:${page}:${bitmap}:${globalIdx}`\n }\n ];\n});\n\n// Add navigation row\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `bn:${bitmap}:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `bn:${bitmap}:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add action buttons if selection exists\nif (selectedCount > 0) {\n keyboard.push([\n { text: `Start (${selectedCount})`, callback_data: `be:start:${bitmap}` },\n { text: `Stop (${selectedCount})`, callback_data: `be:stop:${bitmap}` }\n ]);\n keyboard.push([\n { text: 'Clear', callback_data: 'batch:clear' },\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n} else {\n keyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n ]);\n}\n\nconst totalCount = sortedContainers.length;\nlet message = selectedCount > 0 \n ? `<b>Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}</b>\\n(Tap to select/deselect)`\n : '<b>Select containers for batch action:</b>\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n success: true,\n action: 'keyboard',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n text: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n bitmap: bitmap,\n answerText: ''\n }\n};"
},
"id": "code-rebuild-keyboard-nav",
"name": "Rebuild Keyboard For Nav",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
300
]
},
{
"parameters": {
"jsCode": "// Handle batch:clear callback - reset selection to empty bitmap\nconst triggerData = $('When executed by another workflow').item.json;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\n\nreturn {\n json: {\n success: true,\n action: 'clear_update',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: 0,\n bitmap: '0',\n selectedCount: 0,\n needsKeyboardUpdate: true\n }\n};"
},
"id": "code-handle-clear",
"name": "Handle Clear",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
500
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.UNRAID_HOST }}/graphql",
"authentication": "genericCredentialType",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\"query\": \"query { docker { containers { id names state image } } }\"}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"options": {
"timeout": 15000,
"response": {
"response": {
"errorHandling": "continueRegularOutput"
}
}
},
"genericAuthType": "httpHeaderAuth"
},
"id": "http-fetch-containers-clear",
"name": "Fetch Containers For Clear",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
900,
500
],
"credentials": {
"httpHeaderAuth": {
"id": "unraid-api-key-credential-id",
"name": "Unraid API Key"
}
}
},
{
"parameters": {
"jsCode": "// Rebuild batch selection keyboard after clear with bitmap encoding (empty)\nconst containers = $input.all();\nconst clearData = $('Handle Clear').item.json;\nconst chatId = clearData.chatId;\nconst messageId = clearData.messageId;\nconst queryId = clearData.queryId;\nconst page = 0; // Reset to first page\nconst bitmap = '0'; // Empty bitmap\n\n// Extract container data\nlet allContainers = [];\nfor (const item of containers) {\n if (Array.isArray(item.json)) {\n allContainers = allContainers.concat(item.json);\n } else {\n allContainers.push(item.json);\n }\n}\n\n// Sort: running first, then alphabetically\nconst sortedContainers = allContainers\n .map(c => ({\n name: (c.Names && c.Names[0]) ? c.Names[0].replace(/^\\//, '') : 'unknown',\n state: c.State,\n id: c.Id.substring(0, 12)\n }))\n .sort((a, b) => {\n if (a.state === 'running' && b.state !== 'running') return -1;\n if (a.state !== 'running' && b.state === 'running') return 1;\n return a.name.localeCompare(b.name);\n });\n\n// Pagination\nconst containersPerPage = 6;\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst displayContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard (no selection)\nconst keyboard = displayContainers.map((c, localIdx) => {\n const globalIdx = start + localIdx;\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n return [\n {\n text: `${icon} ${c.name}`,\n callback_data: `b:${page}:${bitmap}:${globalIdx}`\n }\n ];\n});\n\n// Add navigation row\nif (totalPages > 1) {\n const navRow = [];\n if (page > 0) {\n navRow.push({ text: '\\u25C0\\uFE0F Previous', callback_data: `bn:${bitmap}:${page - 1}` });\n }\n navRow.push({ text: `${page + 1}/${totalPages}`, callback_data: 'noop' });\n if (page < totalPages - 1) {\n navRow.push({ text: 'Next \\u25B6\\uFE0F', callback_data: `bn:${bitmap}:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add cancel button only\nkeyboard.push([\n { text: 'Cancel', callback_data: 'batch:cancel' }\n]);\n\nconst totalCount = sortedContainers.length;\nlet message = '<b>Select containers for batch action:</b>\\n(Tap to select/deselect)';\nif (totalPages > 1) {\n message += `\\n\\nShowing ${start + 1}-${Math.min(start + containersPerPage, totalCount)} of ${totalCount}`;\n}\n\nreturn {\n json: {\n success: true,\n action: 'keyboard',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n text: message,\n keyboard: {\n inline_keyboard: keyboard\n },\n bitmap: bitmap,\n answerText: 'Selection cleared'\n }\n};"
},
"id": "code-rebuild-keyboard-clear",
"name": "Rebuild Keyboard After Clear",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1120,
500
]
},
{
"parameters": {
"jsCode": "// Handle batch:cancel callback - return to container list\nconst triggerData = $('When executed by another workflow').item.json;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\n\nreturn {\n json: {\n success: true,\n action: 'cancel',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n page: 0,\n answerText: 'Batch selection cancelled'\n }\n};"
},
"id": "code-handle-cancel",
"name": "Handle Cancel",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
680,
600
]
},
{
"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 }));"
},
"id": "code-normalizer-mode",
"name": "Normalize GraphQL Response (Mode)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
790,
100
]
},
{
"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 }));"
},
"id": "code-normalizer-toggle",
"name": "Normalize GraphQL Response (Toggle)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1230,
100
]
},
{
"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 }));"
},
"id": "code-normalizer-exec",
"name": "Normalize GraphQL Response (Exec)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
790,
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 }));"
},
"id": "code-normalizer-nav",
"name": "Normalize GraphQL Response (Nav)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1010,
300
]
},
{
"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 }));"
},
"id": "code-normalizer-clear",
"name": "Normalize GraphQL Response (Clear)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1010,
500
]
}
],
"connections": {
"When executed by another workflow": {
"main": [
[
{
"node": "Route Batch UI Action",
"type": "main",
"index": 0
}
]
]
},
"Route Batch UI Action": {
"main": [
[
{
"node": "Fetch Containers For Mode",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Toggle",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Nav",
"type": "main",
"index": 0
}
],
[
{
"node": "Fetch Containers For Exec",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Clear",
"type": "main",
"index": 0
}
],
[
{
"node": "Handle Cancel",
"type": "main",
"index": 0
}
]
]
},
"Fetch Containers For Mode": {
"main": [
[
{
"node": "Normalize GraphQL Response (Mode)",
"type": "main",
"index": 0
}
]
]
},
"Handle Toggle": {
"main": [
[
{
"node": "Needs Keyboard Update?",
"type": "main",
"index": 0
}
]
]
},
"Needs Keyboard Update?": {
"main": [
[
{
"node": "Fetch Containers For Update",
"type": "main",
"index": 0
}
]
]
},
"Fetch Containers For Update": {
"main": [
[
{
"node": "Normalize GraphQL Response (Toggle)",
"type": "main",
"index": 0
}
]
]
},
"Fetch Containers For Exec": {
"main": [
[
{
"node": "Normalize GraphQL Response (Exec)",
"type": "main",
"index": 0
}
]
]
},
"Handle Nav": {
"main": [
[
{
"node": "Fetch Containers For Nav",
"type": "main",
"index": 0
}
]
]
},
"Fetch Containers For Nav": {
"main": [
[
{
"node": "Normalize GraphQL Response (Nav)",
"type": "main",
"index": 0
}
]
]
},
"Handle Clear": {
"main": [
[
{
"node": "Fetch Containers For Clear",
"type": "main",
"index": 0
}
]
]
},
"Fetch Containers For Clear": {
"main": [
[
{
"node": "Normalize GraphQL Response (Clear)",
"type": "main",
"index": 0
}
]
]
},
"Normalize GraphQL Response (Mode)": {
"main": [
[
{
"node": "Build Batch Keyboard",
"type": "main",
"index": 0
}
]
]
},
"Normalize GraphQL Response (Toggle)": {
"main": [
[
{
"node": "Rebuild Keyboard After Toggle",
"type": "main",
"index": 0
}
]
]
},
"Normalize GraphQL Response (Exec)": {
"main": [
[
{
"node": "Handle Exec",
"type": "main",
"index": 0
}
]
]
},
"Normalize GraphQL Response (Nav)": {
"main": [
[
{
"node": "Rebuild Keyboard For Nav",
"type": "main",
"index": 0
}
]
]
},
"Normalize GraphQL Response (Clear)": {
"main": [
[
{
"node": "Rebuild Keyboard After Clear",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"callerPolicy": "any"
}
}