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:
@@ -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}×tamps=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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 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")
|
||||
Reference in New Issue
Block a user