diff --git a/n8n-workflow.json b/n8n-workflow.json
index b4d1fee..852e818 100644
--- a/n8n-workflow.json
+++ b/n8n-workflow.json
@@ -187,7 +187,7 @@
"name": "Format Echo",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
- "position": [900, 600]
+ "position": [900, 800]
},
{
"parameters": {
@@ -203,7 +203,7 @@
"name": "Send Echo",
"type": "n8n-nodes-base.telegram",
"typeVersion": 1.2,
- "position": [1120, 600],
+ "position": [1120, 800],
"credentials": {
"telegramApi": {
"id": "telegram-credential",
@@ -220,6 +220,229 @@
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [900, 400]
+ },
+ {
+ "parameters": {
+ "command": "curl -s --unix-socket /var/run/docker.sock 'http://localhost/v1.47/containers/json?all=true'",
+ "options": {}
+ },
+ "id": "exec-docker-list-action",
+ "name": "Docker List for Action",
+ "type": "n8n-nodes-base.executeCommand",
+ "typeVersion": 1,
+ "position": [1120, 400]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Get Docker API response and action info from Parse Action\nconst dockerOutput = $input.item.json.stdout;\nconst actionData = $('Parse Action').item.json;\nconst action = actionData.action;\nconst containerQuery = actionData.containerQuery;\nconst chatId = actionData.chatId;\n\n// Parse JSON response\nlet containers;\ntry {\n if (!dockerOutput || dockerOutput.trim() === '') {\n throw new Error('Empty response');\n }\n containers = JSON.parse(dockerOutput);\n} catch (e) {\n return [{\n json: {\n matchCount: -1,\n error: true,\n chatId: chatId,\n text: \"Can't reach Docker - check if n8n has socket access\"\n }\n }];\n}\n\n// Function to normalize container names\nfunction normalizeName(name) {\n return name\n .replace(/^\\//, '') // Remove leading slash\n .replace(/^(linuxserver[-_]|binhex[-_])/i, '') // Remove common prefixes\n .toLowerCase();\n}\n\n// Find matching containers using fuzzy matching\nconst matches = containers.filter(c => {\n const containerName = normalizeName(c.Names[0]);\n const normalized = containerQuery.toLowerCase();\n return containerName.includes(normalized) || normalized.includes(containerName);\n});\n\n// Return match results with all necessary context\nreturn [{\n json: {\n matches: matches.map(c => ({\n Id: c.Id,\n Name: c.Names[0].replace(/^\\//, ''),\n State: c.State\n })),\n matchCount: matches.length,\n action: action,\n containerQuery: containerQuery,\n chatId: chatId\n }\n}];"
+ },
+ "id": "code-match-container",
+ "name": "Match Container",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [1340, 400]
+ },
+ {
+ "parameters": {
+ "rules": {
+ "values": [
+ {
+ "id": "no-match",
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "count-zero",
+ "leftValue": "={{ $json.matchCount }}",
+ "rightValue": "0",
+ "operator": {
+ "type": "number",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "renameOutput": false
+ },
+ {
+ "id": "single-match",
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "count-one",
+ "leftValue": "={{ $json.matchCount }}",
+ "rightValue": "1",
+ "operator": {
+ "type": "number",
+ "operation": "equals"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "renameOutput": false
+ },
+ {
+ "id": "multiple-matches",
+ "conditions": {
+ "options": {
+ "caseSensitive": true,
+ "leftValue": "",
+ "typeValidation": "strict"
+ },
+ "conditions": [
+ {
+ "id": "count-gt-one",
+ "leftValue": "={{ $json.matchCount }}",
+ "rightValue": "1",
+ "operator": {
+ "type": "number",
+ "operation": "gt"
+ }
+ }
+ ],
+ "combinator": "and"
+ },
+ "renameOutput": false
+ }
+ ]
+ },
+ "options": {
+ "fallbackOutput": "extra"
+ }
+ },
+ "id": "switch-match-count",
+ "name": "Check Match Count",
+ "type": "n8n-nodes-base.switch",
+ "typeVersion": 3.2,
+ "position": [1560, 400]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Build the curl command for the action\nconst data = $input.item.json;\nconst containerId = data.matches[0].Id;\nconst action = data.action;\nconst containerName = data.matches[0].Name;\nconst chatId = data.chatId;\n\n// stop and restart use ?t=10 for graceful timeout\nconst timeout = (action === 'stop' || action === 'restart') ? '?t=10' : '';\n\n// Build curl command that returns HTTP status code\nconst cmd = `curl -s -o /dev/null -w \"%{http_code}\" --unix-socket /var/run/docker.sock -X POST 'http://localhost/v1.47/containers/${containerId}/${action}${timeout}'`;\n\nreturn {\n json: {\n cmd: cmd,\n containerId: containerId,\n containerName: containerName,\n action: action,\n chatId: chatId\n }\n};"
+ },
+ "id": "code-build-action-cmd",
+ "name": "Build Action Command",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [1780, 400]
+ },
+ {
+ "parameters": {
+ "command": "={{ $json.cmd }}",
+ "options": {}
+ },
+ "id": "exec-action",
+ "name": "Execute Action",
+ "type": "n8n-nodes-base.executeCommand",
+ "typeVersion": 1,
+ "position": [2000, 400]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Parse the HTTP status code from curl output\nconst stdout = $input.item.json.stdout;\nconst stderr = $input.item.json.stderr;\nconst actionData = $('Build Action Command').item.json;\nconst containerName = actionData.containerName;\nconst action = actionData.action;\nconst chatId = actionData.chatId;\n\n// Check for curl-level errors first\nif (stderr && stderr.trim()) {\n return {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${stderr.trim()}`\n }\n };\n}\n\nconst statusCode = parseInt(stdout.trim());\n\n// 204: Success, 304: Already in state (also success for user)\nif (statusCode === 204 || statusCode === 304) {\n const verb = action === 'start' ? 'started' :\n action === 'stop' ? 'stopped' : 'restarted';\n return {\n json: {\n success: true,\n chatId: chatId,\n text: `${containerName} ${verb} successfully`\n }\n };\n}\n\n// Handle error codes\nlet errorMsg;\nswitch (statusCode) {\n case 404:\n errorMsg = 'Container not found';\n break;\n case 409:\n errorMsg = 'Container is in a conflicting state';\n break;\n case 500:\n errorMsg = 'Docker server error';\n break;\n default:\n errorMsg = `HTTP ${statusCode}`;\n}\n\nreturn {\n json: {\n success: false,\n chatId: chatId,\n text: `Failed to ${action} ${containerName}: ${errorMsg}`\n }\n};"
+ },
+ "id": "code-parse-action-result",
+ "name": "Parse Action Result",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [2220, 400]
+ },
+ {
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $json.chatId }}",
+ "text": "={{ $json.text }}",
+ "additionalFields": {
+ "parse_mode": "HTML"
+ }
+ },
+ "id": "telegram-send-action-result",
+ "name": "Send Action Result",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [2440, 400],
+ "credentials": {
+ "telegramApi": {
+ "id": "telegram-credential",
+ "name": "Telegram API"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "// No container matched the query\nconst data = $input.item.json;\nreturn {\n json: {\n chatId: data.chatId,\n text: `No container found matching '${data.containerQuery}'`\n }\n};"
+ },
+ "id": "code-no-match",
+ "name": "Format No Match",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [1780, 300]
+ },
+ {
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $json.chatId }}",
+ "text": "={{ $json.text }}",
+ "additionalFields": {
+ "parse_mode": "HTML"
+ }
+ },
+ "id": "telegram-send-no-match",
+ "name": "Send No Match",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [2000, 300],
+ "credentials": {
+ "telegramApi": {
+ "id": "telegram-credential",
+ "name": "Telegram API"
+ }
+ }
+ },
+ {
+ "parameters": {
+ "jsCode": "// Multiple containers matched - placeholder for confirmation flow (Plan 03-02)\nconst data = $input.item.json;\nconst names = data.matches.map(m => m.Name).join(', ');\nreturn {\n json: {\n chatId: data.chatId,\n text: `Found ${data.matchCount} containers matching '${data.containerQuery}': ${names}\\n\\nConfirmation required. (Coming in next update)`\n }\n};"
+ },
+ "id": "code-multiple-matches",
+ "name": "Format Multiple Matches",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [1780, 500]
+ },
+ {
+ "parameters": {
+ "resource": "message",
+ "operation": "sendMessage",
+ "chatId": "={{ $json.chatId }}",
+ "text": "={{ $json.text }}",
+ "additionalFields": {
+ "parse_mode": "HTML"
+ }
+ },
+ "id": "telegram-send-multiple",
+ "name": "Send Multiple Matches",
+ "type": "n8n-nodes-base.telegram",
+ "typeVersion": 1.2,
+ "position": [2000, 500],
+ "credentials": {
+ "telegramApi": {
+ "id": "telegram-credential",
+ "name": "Telegram API"
+ }
+ }
}
],
"connections": {
@@ -315,6 +538,120 @@
}
]
]
+ },
+ "Parse Action": {
+ "main": [
+ [
+ {
+ "node": "Docker List for Action",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Docker List for Action": {
+ "main": [
+ [
+ {
+ "node": "Match Container",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Match Container": {
+ "main": [
+ [
+ {
+ "node": "Check Match Count",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Check Match Count": {
+ "main": [
+ [
+ {
+ "node": "Format No Match",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Build Action Command",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ [
+ {
+ "node": "Format Multiple Matches",
+ "type": "main",
+ "index": 0
+ }
+ ],
+ []
+ ]
+ },
+ "Build Action Command": {
+ "main": [
+ [
+ {
+ "node": "Execute Action",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Execute Action": {
+ "main": [
+ [
+ {
+ "node": "Parse Action Result",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Parse Action Result": {
+ "main": [
+ [
+ {
+ "node": "Send Action Result",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Format No Match": {
+ "main": [
+ [
+ {
+ "node": "Send No Match",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ },
+ "Format Multiple Matches": {
+ "main": [
+ [
+ {
+ "node": "Send Multiple Matches",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
}
},
"pinData": {},