chore(10-05): verify and document workflow refactoring

- Run cleanup and verification script
- No orphaned nodes found
- Workflow structure validated
- Final node count: 199 (reduced from 209, -4.8%)
- Add comprehensive deployment guide

Node composition:
- 79 code nodes
- 50 httpRequest nodes
- 27 telegram nodes
- 14 if nodes
- 10 switch nodes
- 9 executeCommand nodes
- 9 executeWorkflow nodes (sub-workflow calls)
- 1 telegramTrigger node

Note: Node count (199) is above target range (120-150) but achieves
primary goals of eliminating duplicate logic. Further optimization
possible (~40-45 nodes) by consolidating batch UI and confirmation flows.

Deployment requires importing n8n-container-logs.json and updating
the workflow ID in main workflow Execute Text/Inline Logs nodes.
This commit is contained in:
Lucas Berger
2026-02-04 13:58:48 -05:00
parent 6471dcecd6
commit 186f11362e
9 changed files with 1731 additions and 6 deletions
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""
Task 3: Extract logs flow to sub-workflow
"""
import json
import uuid
def create_logs_subworkflow():
"""Create n8n-container-logs.json sub-workflow"""
# Create workflow structure
workflow = {
"name": "Container Logs",
"nodes": [],
"connections": {},
"active": True,
"settings": {
"executionOrder": "v1"
},
"versionId": str(uuid.uuid4()),
"meta": {
"instanceId": "unraid-docker-manager"
},
"tags": []
}
# 1. Execute Workflow Trigger (entry point)
trigger = {
"parameters": {},
"id": str(uuid.uuid4()),
"name": "Execute Workflow Trigger",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1,
"position": [240, 300]
}
# 2. Parse Input - validates and extracts input parameters
parse_input_code = '''// Parse and validate input
const input = $json;
// Get container identifier (ID or name)
const containerId = input.containerId || '';
const containerName = input.containerName || '';
const lineCount = input.lineCount || 50;
const chatId = input.chatId;
const messageId = input.messageId || 0;
const responseMode = input.responseMode || 'text';
if (!containerId && !containerName) {
throw new Error('Either containerId or containerName required');
}
if (!chatId) {
throw new Error('chatId required');
}
return {
json: {
containerId: containerId,
containerName: containerName,
lineCount: Math.min(Math.max(parseInt(lineCount), 1), 1000),
chatId: chatId,
messageId: messageId,
responseMode: responseMode
}
};'''
parse_input = {
"parameters": {
"jsCode": parse_input_code
},
"id": str(uuid.uuid4()),
"name": "Parse Input",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [460, 300]
}
# 3. Get Container ID (if only name provided)
get_container_code = '''// Get container ID if needed
const data = $json;
// If we already have container ID, pass through
if (data.containerId) {
return {
json: {
...data,
useDirectId: true
}
};
}
// Otherwise, need to query Docker to find by name
return {
json: {
...data,
useDirectId: false,
dockerCommand: 'curl -s --max-time 5 "http://docker-socket-proxy:2375/v1.47/containers/json?all=1"'
}
};'''
get_container = {
"parameters": {
"jsCode": get_container_code
},
"id": str(uuid.uuid4()),
"name": "Check Container ID",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [680, 300]
}
# 4. Route based on whether we need to query
route_node = {
"parameters": {
"rules": {
"values": [
{
"id": "has-id",
"conditions": {
"options": {
"caseSensitive": True,
"typeValidation": "loose"
},
"conditions": [
{
"id": "check-direct",
"leftValue": "={{ $json.useDirectId }}",
"rightValue": "true",
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
},
"renameOutput": True,
"outputKey": "direct"
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": str(uuid.uuid4()),
"name": "Route ID Check",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [900, 300]
}
# 5. Query Docker (if needed)
query_docker = {
"parameters": {
"command": "={{ $json.dockerCommand }}"
},
"id": str(uuid.uuid4()),
"name": "Query Docker",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [1120, 400]
}
# 6. Find Container by Name
find_container_code = '''// Find container by name
const dockerOutput = $input.item.json.stdout;
const data = $('Check Container ID').item.json;
const containerName = data.containerName.toLowerCase();
// Parse Docker response
let containers;
try {
containers = JSON.parse(dockerOutput);
} catch (e) {
throw new Error('Failed to parse Docker response');
}
// Normalize name function
function normalizeName(name) {
return name
.replace(/^\//, '')
.replace(/^(linuxserver[-_]|binhex[-_])/i, '')
.toLowerCase();
}
// Find exact match
const container = containers.find(c => normalizeName(c.Names[0]) === containerName);
if (!container) {
throw new Error(`Container "${containerName}" not found`);
}
return {
json: {
...data,
containerId: container.Id,
containerName: normalizeName(container.Names[0])
}
};'''
find_container = {
"parameters": {
"jsCode": find_container_code
},
"id": str(uuid.uuid4()),
"name": "Find Container",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1340, 400]
}
# 7. Build Logs Command
build_command_code = '''// Build Docker logs command
const data = $json;
const containerId = data.containerId;
const lineCount = data.lineCount;
const cmd = `curl -s --max-time 10 "http://docker-socket-proxy:2375/v1.47/containers/${containerId}/logs?stdout=1&stderr=1&tail=${lineCount}&timestamps=1"`;
return {
json: {
...data,
logsCommand: cmd
}
};'''
build_command = {
"parameters": {
"jsCode": build_command_code
},
"id": str(uuid.uuid4()),
"name": "Build Logs Command",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [1560, 300]
}
# 8. Execute Logs Command
execute_logs = {
"parameters": {
"command": "={{ $json.logsCommand }}"
},
"id": str(uuid.uuid4()),
"name": "Execute Logs",
"type": "n8n-nodes-base.executeCommand",
"typeVersion": 1,
"position": [1780, 300]
}
# 9. Format Logs Output
format_logs_code = '''// Format logs output for Telegram
const rawOutput = $input.item.json.stdout || '';
const data = $('Build Logs Command').item.json;
// HTML escape function
function escapeHtml(text) {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Handle empty logs
if (!rawOutput || rawOutput.trim() === '') {
return {
json: {
success: true,
message: `No logs available for <b>${data.containerName}</b>`,
containerName: data.containerName,
lineCount: 0
}
};
}
// Strip Docker binary headers and process lines
const lines = rawOutput.split('\\n')
.filter(line => line.length > 0)
.map(line => {
// Check if line starts with binary header (8-byte Docker stream header)
if (line.length > 8 && line.charCodeAt(0) <= 2) {
return line.substring(8);
}
return line;
})
.join('\\n');
// Truncate for Telegram (4096 char limit, leave room for header)
const maxLen = 3800;
const truncated = lines.length > maxLen
? lines.substring(0, maxLen) + '\\n... (truncated)'
: lines;
// Escape HTML entities
const escaped = escapeHtml(truncated);
const lineCount = lines.split('\\n').length;
const header = `Logs for <b>${data.containerName}</b> (last ${lineCount} lines):\\n\\n`;
const formatted = header + '<pre>' + escaped + '</pre>';
return {
json: {
success: true,
message: formatted,
containerName: data.containerName,
lineCount: lineCount
}
};'''
format_logs = {
"parameters": {
"jsCode": format_logs_code
},
"id": str(uuid.uuid4()),
"name": "Format Logs",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [2000, 300]
}
# Add all nodes
workflow['nodes'] = [
trigger,
parse_input,
get_container,
route_node,
query_docker,
find_container,
build_command,
execute_logs,
format_logs
]
# Add connections
workflow['connections'] = {
"Execute Workflow Trigger": {
"main": [[{"node": "Parse Input", "type": "main", "index": 0}]]
},
"Parse Input": {
"main": [[{"node": "Check Container ID", "type": "main", "index": 0}]]
},
"Check Container ID": {
"main": [[{"node": "Route ID Check", "type": "main", "index": 0}]]
},
"Route ID Check": {
"main": [
[{"node": "Build Logs Command", "type": "main", "index": 0}], # direct path
[{"node": "Query Docker", "type": "main", "index": 0}] # query path
]
},
"Query Docker": {
"main": [[{"node": "Find Container", "type": "main", "index": 0}]]
},
"Find Container": {
"main": [[{"node": "Build Logs Command", "type": "main", "index": 0}]]
},
"Build Logs Command": {
"main": [[{"node": "Execute Logs", "type": "main", "index": 0}]]
},
"Execute Logs": {
"main": [[{"node": "Format Logs", "type": "main", "index": 0}]]
}
}
return workflow
def save_logs_workflow(workflow):
with open('n8n-container-logs.json', 'w') as f:
json.dump(workflow, f, indent=2)
print(f"Created n8n-container-logs.json with {len(workflow['nodes'])} nodes")
if __name__ == '__main__':
print("Creating Container Logs sub-workflow...")
workflow = create_logs_subworkflow()
save_logs_workflow(workflow)
print("✓ Container Logs sub-workflow created")