1f6de5542a
- Replace 3 Docker API GET queries with Unraid GraphQL POST queries
- Add GraphQL Response Normalizer after each query (transforms Unraid format to Docker contract)
- Add Container ID Registry update after each normalizer (keeps name-to-PrefixedID mapping fresh)
- Rename HTTP Request nodes: Docker List → Query Containers, Docker Get → Query Container Status
- Wire pattern: HTTP Request → Normalizer → Registry Update → existing Code node
- Downstream Code nodes unchanged (Build Container List, Build Container Submenu, Build Paginated List)
- GraphQL query: docker.containers {id, names, state, image, status}
- State mapping: RUNNING→running, STOPPED→exited, PAUSED→paused
- Authentication: n8n Header Auth credential "Unraid API Key"
- Timeout: 15s for myunraid.net cloud relay
- Workflow nodes: 11 → 17 (added 3 normalizers + 3 registry updates)
591 lines
32 KiB
JSON
591 lines
32 KiB
JSON
{
|
|
"name": "Container Status",
|
|
"nodes": [
|
|
{
|
|
"parameters": {
|
|
"inputSource": "passthrough",
|
|
"schema": {
|
|
"schemaType": "fromFields",
|
|
"fields": [
|
|
{
|
|
"fieldName": "chatId",
|
|
"fieldType": "number"
|
|
},
|
|
{
|
|
"fieldName": "messageId",
|
|
"fieldType": "number"
|
|
},
|
|
{
|
|
"fieldName": "action",
|
|
"fieldType": "string"
|
|
},
|
|
{
|
|
"fieldName": "containerId",
|
|
"fieldType": "string"
|
|
},
|
|
{
|
|
"fieldName": "containerName",
|
|
"fieldType": "string"
|
|
},
|
|
{
|
|
"fieldName": "page",
|
|
"fieldType": "number"
|
|
},
|
|
{
|
|
"fieldName": "queryId",
|
|
"fieldType": "string"
|
|
},
|
|
{
|
|
"fieldName": "searchTerm",
|
|
"fieldType": "string"
|
|
},
|
|
{
|
|
"fieldName": "correlationId",
|
|
"fieldType": "string"
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"id": "status-trigger",
|
|
"name": "When executed by another workflow",
|
|
"type": "n8n-nodes-base.executeWorkflowTrigger",
|
|
"typeVersion": 1.1,
|
|
"position": [
|
|
240,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"rules": {
|
|
"values": [
|
|
{
|
|
"id": "route-list",
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"typeValidation": "loose"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "is-list",
|
|
"leftValue": "={{ $json.action }}",
|
|
"rightValue": "list",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"renameOutput": true,
|
|
"outputKey": "list"
|
|
},
|
|
{
|
|
"id": "route-status",
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"typeValidation": "loose"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "is-status",
|
|
"leftValue": "={{ $json.action }}",
|
|
"rightValue": "status",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"renameOutput": true,
|
|
"outputKey": "status"
|
|
},
|
|
{
|
|
"id": "route-paginate",
|
|
"conditions": {
|
|
"options": {
|
|
"caseSensitive": true,
|
|
"typeValidation": "loose"
|
|
},
|
|
"conditions": [
|
|
{
|
|
"id": "is-paginate",
|
|
"leftValue": "={{ $json.action }}",
|
|
"rightValue": "paginate",
|
|
"operator": {
|
|
"type": "string",
|
|
"operation": "equals"
|
|
}
|
|
}
|
|
],
|
|
"combinator": "and"
|
|
},
|
|
"renameOutput": true,
|
|
"outputKey": "paginate"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"fallbackOutput": "none"
|
|
}
|
|
},
|
|
"id": "status-route-action",
|
|
"name": "Route Action",
|
|
"type": "n8n-nodes-base.switch",
|
|
"typeVersion": 3.2,
|
|
"position": [
|
|
460,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Prepare list request - pass through input data\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n messageId: data.messageId || 0,\n page: data.page || 0,\n searchTerm: data.searchTerm || null,\n queryId: data.queryId || null\n }\n};"
|
|
},
|
|
"id": "status-prepare-list",
|
|
"name": "Prepare List Request",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
680,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.UNRAID_HOST }}/graphql",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpHeaderAuth",
|
|
"sendBody": true,
|
|
"bodyParameters": {
|
|
"parameters": []
|
|
},
|
|
"specifyBody": "json",
|
|
"jsonBody": "{\"query\": \"query {\\n docker {\\n containers {\\n id\\n names\\n state\\n image\\n status\\n }\\n }\\n}\"}",
|
|
"options": {
|
|
"timeout": 15000,
|
|
"response": {
|
|
"response": {
|
|
"fullResponse": false
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "status-docker-list",
|
|
"name": "Query Containers",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
900,
|
|
200
|
|
],
|
|
"credentials": {
|
|
"httpHeaderAuth": {
|
|
"id": "unraid-api-key-credential-id",
|
|
"name": "Unraid API Key"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Build Paginated Container List Keyboard\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare List Request\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst page = prevData.page || 0;\nconst searchTerm = prevData.searchTerm;\n\n// Function to normalize container names (strip prefixes)\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// If specific container requested via search term, check for exact/single match\nif (searchTerm) {\n const matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n return containerName.includes(searchTerm.toLowerCase());\n });\n \n if (matches.length === 1) {\n const container = matches[0];\n const containerName = normalizeName(container.Names[0]);\n const state = container.State;\n const status = container.Status;\n const image = container.Image;\n \n // Build action keyboard based on container state\n const keyboard = [];\n if (state === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n } else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n }\n keyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n ]);\n keyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n ]);\n \n // Build status text\n const stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n let text = `${stateIcon} <b>${containerName}</b>\\n\\n`;\n text += `<b>State:</b> ${state}\\n`;\n text += `<b>Status:</b> ${status}\\n`;\n text += `<b>Image:</b> ${image}`;\n \n return [{\n json: {\n success: true,\n action: 'status_direct',\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard },\n container: { id: container.Id, name: containerName, state, status, image }\n }\n }];\n } else if (matches.length === 0) {\n return [{\n json: {\n success: false,\n action: 'list',\n chatId,\n messageId,\n error: `No container found matching \"${searchTerm}\"`,\n text: `No container found matching \"${searchTerm}\"`\n }\n }];\n }\n // Multiple matches - continue to show list\n}\n\nconst containersPerPage = 6;\n\n// Group by state (running first)\nconst running = containers.filter(c => c.State === 'running');\nconst stopped = containers.filter(c => c.State !== 'running');\nconst sortedContainers = [...running, ...stopped];\n\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst pageContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard rows\nconst keyboard = [];\n\npageContainers.forEach(container => {\n const name = normalizeName(container.Names[0]);\n const stateIcon = container.State === 'running' ? '\\u{1F7E2}' : '\\u26AA'; // Green circle or white circle\n const stateText = container.State === 'running' ? 'Running' : 'Stopped';\n keyboard.push([{\n text: `${stateIcon} ${name} - ${stateText}`,\n callback_data: `select:${name}`\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: `list:${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: `list:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add Update All button\nkeyboard.push([{\n text: '\\u{1F504} Update All :latest',\n callback_data: 'uall:start'\n}]);\n\n// Add Select Multiple button for batch operations\nkeyboard.push([{ text: '\\u2611\\ufe0f Select Multiple', callback_data: 'batch:mode' }]);\n\n// Build header text\nconst runningCount = running.length;\nconst totalCount = sortedContainers.length;\nlet headerText = `<b>\\u{1F5C2} Containers</b> (${runningCount}/${totalCount} running)`;\nif (totalPages > 1) {\n headerText += `\\n\\nPage ${page + 1} of ${totalPages}`;\n}\nheaderText += '\\n\\nTap a container to manage it:';\n\nreturn [{\n json: {\n success: true,\n action: 'list',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
|
|
},
|
|
"id": "status-build-list",
|
|
"name": "Build Container List",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1120,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Prepare status request - pass through input data\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n messageId: data.messageId || 0,\n containerName: data.containerName,\n containerId: data.containerId || null,\n queryId: data.queryId || null\n }\n};"
|
|
},
|
|
"id": "status-prepare-status",
|
|
"name": "Prepare Status Request",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
680,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.UNRAID_HOST }}/graphql",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpHeaderAuth",
|
|
"sendBody": true,
|
|
"bodyParameters": {
|
|
"parameters": []
|
|
},
|
|
"specifyBody": "json",
|
|
"jsonBody": "{\"query\": \"query {\\n docker {\\n containers {\\n id\\n names\\n state\\n image\\n status\\n }\\n }\\n}\"}",
|
|
"options": {
|
|
"timeout": 15000,
|
|
"response": {
|
|
"response": {
|
|
"fullResponse": false
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "status-docker-single",
|
|
"name": "Query Container Status",
|
|
"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": "// Build Container Submenu for callback selection\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Status Request\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst searchName = prevData.containerName.toLowerCase();\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\n// Find the matching container\nconst container = containers.find(c => normalizeName(c.Names[0]) === searchName);\n\nif (!container) {\n return [{\n json: {\n success: false,\n action: 'status',\n chatId,\n messageId,\n error: `Container \"${searchName}\" not found`,\n text: `Container \"${searchName}\" not found`,\n reply_markup: { inline_keyboard: [[{ text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }]] }\n }\n }];\n}\n\nconst containerName = normalizeName(container.Names[0]);\nconst state = container.State;\nconst status = container.Status;\nconst image = container.Image;\n\n// Build action keyboard based on container state\nconst keyboard = [];\n\nif (state === 'running') {\n keyboard.push([\n { text: '\\u23F9\\uFE0F Stop', callback_data: `action:stop:${containerName}` },\n { text: '\\u{1F504} Restart', callback_data: `action:restart:${containerName}` }\n ]);\n} else {\n keyboard.push([\n { text: '\\u25B6\\uFE0F Start', callback_data: `action:start:${containerName}` }\n ]);\n}\n\nkeyboard.push([\n { text: '\\u{1F4CB} Logs', callback_data: `action:logs:${containerName}` },\n { text: '\\u2B06\\uFE0F Update', callback_data: `action:update:${containerName}` }\n]);\n\nkeyboard.push([\n { text: '\\u25C0\\uFE0F Back to List', callback_data: 'list:0' }\n]);\n\n// Build status text\nconst stateIcon = state === 'running' ? '\\u{1F7E2}' : '\\u26AA';\nlet text = `${stateIcon} <b>${containerName}</b>\\n\\n`;\ntext += `<b>State:</b> ${state}\\n`;\ntext += `<b>Status:</b> ${status}\\n`;\ntext += `<b>Image:</b> ${image}`;\n\nreturn [{\n json: {\n success: true,\n action: 'status',\n chatId,\n messageId,\n text,\n reply_markup: { inline_keyboard: keyboard },\n container: { id: container.Id, name: containerName, state, status, image }\n }\n}];"
|
|
},
|
|
"id": "status-build-submenu",
|
|
"name": "Build Container Submenu",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1120,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Prepare paginate request - pass through input data\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n messageId: data.messageId || 0,\n page: data.page || 0,\n queryId: data.queryId || null\n }\n};"
|
|
},
|
|
"id": "status-prepare-paginate",
|
|
"name": "Prepare Paginate Request",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
680,
|
|
400
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"method": "POST",
|
|
"url": "={{ $env.UNRAID_HOST }}/graphql",
|
|
"authentication": "genericCredentialType",
|
|
"genericAuthType": "httpHeaderAuth",
|
|
"sendBody": true,
|
|
"bodyParameters": {
|
|
"parameters": []
|
|
},
|
|
"specifyBody": "json",
|
|
"jsonBody": "{\"query\": \"query {\\n docker {\\n containers {\\n id\\n names\\n state\\n image\\n status\\n }\\n }\\n}\"}",
|
|
"options": {
|
|
"timeout": 15000,
|
|
"response": {
|
|
"response": {
|
|
"fullResponse": false
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"id": "status-docker-paginate",
|
|
"name": "Query Containers For Paginate",
|
|
"type": "n8n-nodes-base.httpRequest",
|
|
"typeVersion": 4.2,
|
|
"position": [
|
|
900,
|
|
400
|
|
],
|
|
"credentials": {
|
|
"httpHeaderAuth": {
|
|
"id": "unraid-api-key-credential-id",
|
|
"name": "Unraid API Key"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Build Paginated Container List Keyboard for pagination callbacks\nconst containers = $input.all().map(item => item.json);\nconst prevData = $(\"Prepare Paginate Request\").item.json;\nconst chatId = prevData.chatId;\nconst messageId = prevData.messageId;\nconst page = prevData.page || 0;\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '')\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '')\n .toLowerCase();\n}\n\nconst containersPerPage = 6;\n\n// Group by state (running first)\nconst running = containers.filter(c => c.State === 'running');\nconst stopped = containers.filter(c => c.State !== 'running');\nconst sortedContainers = [...running, ...stopped];\n\nconst totalPages = Math.ceil(sortedContainers.length / containersPerPage);\nconst start = page * containersPerPage;\nconst pageContainers = sortedContainers.slice(start, start + containersPerPage);\n\n// Build keyboard rows\nconst keyboard = [];\n\npageContainers.forEach(container => {\n const name = normalizeName(container.Names[0]);\n const stateIcon = container.State === 'running' ? '\\u{1F7E2}' : '\\u26AA';\n const stateText = container.State === 'running' ? 'Running' : 'Stopped';\n keyboard.push([{\n text: `${stateIcon} ${name} - ${stateText}`,\n callback_data: `select:${name}`\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: `list:${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: `list:${page + 1}` });\n }\n keyboard.push(navRow);\n}\n\n// Add Update All button\nkeyboard.push([{\n text: '\\u{1F504} Update All :latest',\n callback_data: 'uall:start'\n}]);\n\n// Add Select Multiple button for batch operations\nkeyboard.push([{ text: '\\u2611\\ufe0f Select Multiple', callback_data: 'batch:mode' }]);\n\n// Build header text\nconst runningCount = running.length;\nconst totalCount = sortedContainers.length;\nlet headerText = `<b>\\u{1F5C2} Containers</b> (${runningCount}/${totalCount} running)`;\nif (totalPages > 1) {\n headerText += `\\n\\nPage ${page + 1} of ${totalPages}`;\n}\nheaderText += '\\n\\nTap a container to manage it:';\n\nreturn [{\n json: {\n success: true,\n action: 'paginate',\n chatId,\n messageId,\n text: headerText,\n reply_markup: { inline_keyboard: keyboard },\n totalContainers: totalCount,\n currentPage: page,\n totalPages\n }\n}];"
|
|
},
|
|
"id": "status-build-paginated",
|
|
"name": "Build Paginated List",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1120,
|
|
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: container.status || dockerState, // Use Unraid status field or fallback to state\n Image: container.image || '', // Unraid provides image field\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": "status-normalizer-list",
|
|
"name": "Normalize GraphQL Response (List)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1000,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"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: container.status || dockerState, // Use Unraid status field or fallback to state\n Image: container.image || '', // Unraid provides image field\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": "status-normalizer-status",
|
|
"name": "Normalize GraphQL Response (Status)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1000,
|
|
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: container.status || dockerState, // Use Unraid status field or fallback to state\n Image: container.image || '', // Unraid provides image field\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": "status-normalizer-paginate",
|
|
"name": "Normalize GraphQL Response (Paginate)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1000,
|
|
400
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Update Container ID Registry with fresh container data\nconst containers = $input.all().map(item => item.json);\n\n// Initialize registry using static data with JSON serialization pattern\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\n\nconst newMap = {};\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 newMap[name] = {\n name: name,\n unraidId: container.Id // Full PrefixedID from normalized data\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(newMap);\n\n// Pass through all containers unchanged\nreturn $input.all();"
|
|
},
|
|
"id": "status-registry-list",
|
|
"name": "Update Container Registry (List)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1060,
|
|
200
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Update Container ID Registry with fresh container data\nconst containers = $input.all().map(item => item.json);\n\n// Initialize registry using static data with JSON serialization pattern\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\n\nconst newMap = {};\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 newMap[name] = {\n name: name,\n unraidId: container.Id // Full PrefixedID from normalized data\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(newMap);\n\n// Pass through all containers unchanged\nreturn $input.all();"
|
|
},
|
|
"id": "status-registry-status",
|
|
"name": "Update Container Registry (Status)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1060,
|
|
300
|
|
]
|
|
},
|
|
{
|
|
"parameters": {
|
|
"jsCode": "// Update Container ID Registry with fresh container data\nconst containers = $input.all().map(item => item.json);\n\n// Initialize registry using static data with JSON serialization pattern\nconst registry = $getWorkflowStaticData('global');\nif (!registry._containerIdMap) {\n registry._containerIdMap = JSON.stringify({});\n}\n\nconst newMap = {};\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 newMap[name] = {\n name: name,\n unraidId: container.Id // Full PrefixedID from normalized data\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(newMap);\n\n// Pass through all containers unchanged\nreturn $input.all();"
|
|
},
|
|
"id": "status-registry-paginate",
|
|
"name": "Update Container Registry (Paginate)",
|
|
"type": "n8n-nodes-base.code",
|
|
"typeVersion": 2,
|
|
"position": [
|
|
1060,
|
|
400
|
|
]
|
|
}
|
|
],
|
|
"connections": {
|
|
"When executed by another workflow": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Route Action",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Route Action": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Prepare List Request",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Prepare Status Request",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
],
|
|
[
|
|
{
|
|
"node": "Prepare Paginate Request",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Prepare List Request": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Query Containers",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Query Containers": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalize GraphQL Response (List)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalize GraphQL Response (List)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Update Container Registry (List)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Update Container Registry (List)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Build Container List",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Prepare Status Request": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Query Container Status",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Query Container Status": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalize GraphQL Response (Status)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalize GraphQL Response (Status)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Update Container Registry (Status)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Update Container Registry (Status)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Build Container Submenu",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Prepare Paginate Request": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Query Containers For Paginate",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Query Containers For Paginate": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Normalize GraphQL Response (Paginate)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Normalize GraphQL Response (Paginate)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Update Container Registry (Paginate)",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
},
|
|
"Update Container Registry (Paginate)": {
|
|
"main": [
|
|
[
|
|
{
|
|
"node": "Build Paginated List",
|
|
"type": "main",
|
|
"index": 0
|
|
}
|
|
]
|
|
]
|
|
}
|
|
},
|
|
"pinData": {},
|
|
"meta": {
|
|
"templateCredsSetupCompleted": true,
|
|
"instanceId": "placeholder"
|
|
},
|
|
"settings": {
|
|
"executionOrder": "v1"
|
|
},
|
|
"staticData": null,
|
|
"tags": []
|
|
} |