diff --git a/n8n-batch-ui.json b/n8n-batch-ui.json index 2332a80..02fd7d5 100644 --- a/n8n-batch-ui.json +++ b/n8n-batch-ui.json @@ -32,12 +32,12 @@ "fieldType": "number" }, { - "fieldName": "selectedCsv", + "fieldName": "bitmap", "fieldType": "string" }, { - "fieldName": "toggleName", - "fieldType": "string" + "fieldName": "containerIndex", + "fieldType": "number" }, { "fieldName": "batchAction", @@ -234,7 +234,7 @@ }, { "parameters": { - "jsCode": "// Build batch selection keyboard with toggle checkmarks and pagination\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 selectedCsv = triggerData.selectedCsv || '';\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\nconst selectedCount = selectedSet.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 => {\n const isSelected = selectedSet.has(c.name);\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: `batch:toggle:${page}:${selectedCsv}:${c.name}`\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: `batch:nav:${page - 1}:${selectedCsv}` });\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: `batch:nav:${page + 1}:${selectedCsv}` });\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: `batch:exec:start:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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 selectedCsv: selectedCsv,\n selectedCount: selectedCount,\n answerText: selectedCount > 0 ? `${selectedCount} selected` : 'Select containers...'\n }\n};" + "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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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", @@ -247,7 +247,7 @@ }, { "parameters": { - "jsCode": "// Handle container toggle in batch selection\nconst triggerData = $('When executed by another workflow').item.json;\nconst selectedCsv = triggerData.selectedCsv || '';\nconst toggleName = triggerData.toggleName;\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\nconst page = triggerData.batchPage || 0;\n\n// Parse current selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\n\n// Toggle the container\nif (selectedSet.has(toggleName)) {\n selectedSet.delete(toggleName);\n} else {\n selectedSet.add(toggleName);\n}\n\n// Convert back to CSV\nconst newSelected = Array.from(selectedSet).filter(n => n).join(',');\n\n// Calculate callback size limit (64 bytes)\nconst callbackPrefix = 'batch:toggle:';\nconst longestName = 20;\nconst maxCsvLength = 64 - callbackPrefix.length - longestName - 2;\n\n// Check if we're at limit\nif (newSelected.length > maxCsvLength) {\n return {\n json: {\n success: false,\n action: 'limit_reached',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: page,\n selectedCsv: selectedCsv,\n answerText: 'Maximum selection reached',\n showAlert: true\n }\n };\n}\n\nreturn {\n json: {\n success: true,\n action: 'toggle_update',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: page,\n selectedCsv: newSelected,\n selectedCount: selectedSet.size,\n needsKeyboardUpdate: true\n }\n};" + "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", @@ -307,7 +307,7 @@ }, { "parameters": { - "jsCode": "// Rebuild batch selection keyboard with updated checkmarks\nconst containers = $input.all();\nconst toggleData = $('Handle Toggle').item.json;\nconst selectedCsv = toggleData.selectedCsv || '';\nconst selectedCount = toggleData.selectedCount || 0;\nconst chatId = toggleData.chatId;\nconst messageId = toggleData.messageId;\nconst queryId = toggleData.queryId;\nconst page = toggleData.batchPage || 0;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\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 => {\n const isSelected = selectedSet.has(c.name);\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: `batch:toggle:${page}:${selectedCsv}:${c.name}`\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: `batch:nav:${page - 1}:${selectedCsv}` });\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: `batch:nav:${page + 1}:${selectedCsv}` });\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: `batch:exec:start:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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 selectedCsv: selectedCsv,\n answerText: ''\n }\n};" + "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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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", @@ -320,12 +320,15 @@ }, { "parameters": { - "jsCode": "// Handle batch:exec callback - determine if confirmation needed\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 selected containers from trigger data\nconst batchAction = triggerData.batchAction || 'start';\nconst selectedCsv = triggerData.selectedCsv || '';\n\n// Split names\nconst containerNames = selectedCsv.split(',').filter(n => n);\nconst count = containerNames.length;\n\n// Check if stop action (needs confirmation per CONTEXT)\nconst needsConfirmation = batchAction === 'stop';\n\nif (needsConfirmation) {\n // Build stop confirmation keyboard\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:${selectedCsv}:${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 selectedCsv: selectedCsv,\n count: count,\n fromKeyboard: true,\n answerText: `Starting batch ${batchAction}...`\n }\n};" + "url": "http://docker-socket-proxy:2375/containers/json?all=true", + "options": { + "timeout": 5000 + } }, - "id": "code-handle-exec", - "name": "Handle Exec", - "type": "n8n-nodes-base.code", - "typeVersion": 2, + "id": "http-fetch-containers-exec", + "name": "Fetch Containers For Exec", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, "position": [ 680, 400 @@ -333,7 +336,20 @@ }, { "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 selectedCsv = triggerData.selectedCsv || '';\nconst chatId = triggerData.chatId;\nconst messageId = triggerData.messageId;\nconst queryId = triggerData.queryId;\n\nreturn {\n json: {\n success: true,\n action: 'nav_update',\n queryId: queryId,\n chatId: chatId,\n messageId: messageId,\n batchPage: page,\n selectedCsv: selectedCsv,\n selectedCount: selectedCsv ? selectedCsv.split(',').filter(n => n).length : 0,\n needsKeyboardUpdate: true\n }\n};" + "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", @@ -362,7 +378,7 @@ }, { "parameters": { - "jsCode": "// Rebuild batch selection keyboard for navigation\nconst containers = $input.all();\nconst navData = $('Handle Nav').item.json;\nconst selectedCsv = navData.selectedCsv || '';\nconst selectedCount = navData.selectedCount || 0;\nconst chatId = navData.chatId;\nconst messageId = navData.messageId;\nconst queryId = navData.queryId;\nconst page = navData.batchPage || 0;\n\n// Parse selection\nconst selectedSet = new Set(selectedCsv ? selectedCsv.split(',') : []);\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 => {\n const isSelected = selectedSet.has(c.name);\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: `batch:toggle:${page}:${selectedCsv}:${c.name}`\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: `batch:nav:${page - 1}:${selectedCsv}` });\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: `batch:nav:${page + 1}:${selectedCsv}` });\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: `batch:exec:start:${selectedCsv}` },\n { text: `Stop (${selectedCount})`, callback_data: `batch:exec:stop:${selectedCsv}` }\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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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 selectedCsv: selectedCsv,\n answerText: ''\n }\n};" + "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 ? `Selected: ${selectedCount} container${selectedCount !== 1 ? 's' : ''}\\n(Tap to select/deselect)`\n : 'Select containers for batch action:\\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", @@ -375,7 +391,7 @@ }, { "parameters": { - "jsCode": "// Handle batch:clear callback - reset selection to empty\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 selectedCsv: '',\n selectedCount: 0,\n needsKeyboardUpdate: true\n }\n};" + "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", @@ -404,7 +420,7 @@ }, { "parameters": { - "jsCode": "// Rebuild batch selection keyboard after clear\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\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 => {\n const icon = c.state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n return [\n {\n text: `${icon} ${c.name}`,\n callback_data: `batch:toggle:${page}::${c.name}`\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: `batch:nav:${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: `batch:nav:${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 = 'Select containers for batch action:\\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 selectedCsv: '',\n answerText: 'Selection cleared'\n }\n};" + "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 = 'Select containers for batch action:\\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", @@ -466,7 +482,7 @@ ], [ { - "node": "Handle Exec", + "node": "Fetch Containers For Exec", "type": "main", "index": 0 } @@ -531,6 +547,17 @@ ] ] }, + "Fetch Containers For Exec": { + "main": [ + [ + { + "node": "Handle Exec", + "type": "main", + "index": 0 + } + ] + ] + }, "Handle Nav": { "main": [ [ @@ -580,4 +607,4 @@ "executionOrder": "v1", "callerPolicy": "any" } -} \ No newline at end of file +}