#!/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, '>'); } // Handle empty logs if (!rawOutput || rawOutput.trim() === '') { return { json: { success: true, message: `No logs available for ${data.containerName}`, 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 ${data.containerName} (last ${lineCount} lines):\\n\\n`; const formatted = header + '
' + escaped + ''; 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")