186f11362e
- 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.
376 lines
10 KiB
Python
376 lines
10 KiB
Python
#!/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")
|