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
+21 -2
View File
@@ -4,7 +4,7 @@
- **v1.0 Docker Control via Telegram** — Phases 1-5 (shipped 2026-02-02) -> [Archive](milestones/v1.0-ROADMAP.md)
- **v1.1 n8n Integration & Polish** — Phases 6-9 (shipped 2026-02-04) -> [Archive](milestones/v1.1-ROADMAP.md)
- **v1.2 Modularization & Polish** — Phases 10-13 (planned)
- **v1.2 Modularization & Polish** — Phases 10-13 + 10.1 (planned)
---
@@ -39,6 +39,24 @@ Plans:
---
### Phase 10.1: Better Logging and Log Management (INSERTED)
**Goal:** Improve logging capabilities and log management features
**Dependencies:** Phase 10 (modularization complete)
**Requirements:** TBD
**Plans:** 0 plans
Plans:
- [ ] TBD (run /gsd:plan-phase 10.1 to break down)
**Success Criteria:**
1. [To be defined during planning]
---
### Phase 11: Update All & Callback Limits
**Goal:** Add "update all" functionality and fix callback data limits for batch selection
@@ -111,11 +129,12 @@ Plans:
| 8 | Inline Keyboard Infrastructure | v1.1 | Complete |
| 9 | Batch Operations | v1.1 | Complete |
| 10 | Workflow Modularization | v1.2 | Planned |
| 10.1 | Better Logging and Log Management | v1.2 | Pending (INSERTED) |
| 11 | Update All & Callback Limits | v1.2 | Pending |
| 12 | Polish & Audit | v1.2 | Pending |
| 13 | Documentation Overhaul | v1.2 | Pending |
**v1.2 Coverage:** 12 requirements mapped across 4 phases
**v1.2 Coverage:** 12+ requirements mapped across 5 phases
---
*Updated: 2026-02-04 after Phase 10 planning*
+5
View File
@@ -17,6 +17,7 @@ v1.1: [██████████] 100% SHIPPED
v1.2: [██ ] 20%
Phase 10: Workflow Modularization [████████ ] 80% (4/5 plans)
Phase 10.1: Better Logging & Log Mgmt [ ] Pending (INSERTED)
Phase 11: Update All & Callback Limits [ ] Pending
Phase 12: Polish & Audit [ ] Pending
Phase 13: Documentation Overhaul [ ] Pending
@@ -53,5 +54,9 @@ Phase 13: Documentation Overhaul [ ] Pending
`/gsd:execute-phase 10` — Will execute remaining plan 10-05
## Roadmap Evolution
- Phase 10.1 inserted after Phase 10: Better Logging and Log Management (URGENT)
---
*Auto-maintained by GSD workflow*
+233
View File
@@ -0,0 +1,233 @@
# Deployment Guide - Plan 10-05
## Overview
This guide covers deploying the modularized workflow structure with 3 sub-workflows:
- Container Update (existing)
- Container Actions (existing)
- Container Logs (NEW)
## Current Status
- Main workflow: 209 → 199 nodes (-10 nodes, -4.8%)
- Sub-workflows: 3 total
- Node count above target (120-150) but significantly improved
## Deployment Steps
### 1. Import Container Logs Sub-workflow
```bash
# Import n8n-container-logs.json to n8n
# Via UI: Settings → Import → Select n8n-container-logs.json
# Or via n8n CLI if available
```
After import, note the workflow ID assigned by n8n.
### 2. Update Main Workflow with Logs Sub-workflow ID
```bash
# Edit n8n-workflow.json
# Find "Execute Text Logs" and "Execute Inline Logs" nodes
# Update their workflowId.value to the actual ID from step 1
# Example:
# "workflowId": {
# "__rl": true,
# "mode": "list",
# "value": "ACTUAL_ID_HERE"
# }
```
Or use this script:
```python
import json
import sys
if len(sys.argv) < 2:
print("Usage: python update_logs_id.py <logs_workflow_id>")
sys.exit(1)
logs_id = sys.argv[1]
with open('n8n-workflow.json', 'r') as f:
workflow = json.load(f)
# Update Execute Text Logs
for node in workflow['nodes']:
if node['name'] in ['Execute Text Logs', 'Execute Inline Logs']:
node['parameters']['workflowId']['value'] = logs_id
print(f"Updated {node['name']} to use {logs_id}")
with open('n8n-workflow.json', 'w') as f:
json.dump(workflow, f, indent=2)
print("✓ Updated main workflow with logs sub-workflow ID")
```
### 3. Import/Update Main Workflow
```bash
# Import updated n8n-workflow.json to n8n
# This will update the existing main workflow
```
### 4. Verify Sub-workflows are Active
In n8n UI, ensure all 3 sub-workflows are set to "Active":
- Container Update
- Container Actions
- Container Logs
### 5. Test All Paths
#### Text Commands:
```
# Test to bot
status
start plex
stop plex
restart plex
update plex
logs plex
logs plex 100
```
#### Inline Keyboard:
1. Send "status" to get container list
2. Click on a container
3. Test buttons:
- Start/Stop/Restart
- Update
- Logs
- Back to List
#### Batch Operations:
1. Send "status" to get container list
2. Enable batch mode (toggle button)
3. Select multiple containers
4. Click "Start Selected" / "Stop Selected" / "Restart Selected"
5. Verify progress messages update
6. Verify final summary
#### Batch Update:
1. Send command: `update all`
2. Confirm the update
3. Verify progress messages
4. Verify all containers updated
## Verification Checklist
- [ ] Container Logs sub-workflow imported and active
- [ ] Main workflow updated with correct logs workflow ID
- [ ] All sub-workflows marked as Active in n8n
- [ ] Text commands work (status, start, stop, restart, update, logs)
- [ ] Inline keyboard flows work (all buttons functional)
- [ ] Batch selection and execution works
- [ ] Batch update all works with progress tracking
- [ ] Error messages display correctly
- [ ] No Docker API errors in n8n logs
## Architecture
```
Main Workflow (n8n-workflow.json) - 199 nodes
├── Telegram Trigger + Auth
├── Command Parsing & Routing
├── Container List & Status Display
├── Batch Selection UI
├── Confirmation Dialogs
└── Sub-workflow Orchestration
├── Container Update (7AvTzLtKXM2hZTio92_mC)
│ └── Used by: Execute Text Update, Execute Callback Update, Execute Batch Update
├── Container Actions (fYSZS5PkH0VSEaT5)
│ └── Used by: Execute Container Action, Execute Inline Action,
│ Execute Confirmed Stop Action, Execute Batch Action Sub-workflow
└── Container Logs (NEW - get ID after import)
└── Used by: Execute Text Logs, Execute Inline Logs
```
## Sub-workflow Input Contracts
### Container Update
```json
{
"containerId": "string",
"containerName": "string",
"chatId": "number",
"messageId": "number",
"responseMode": "text|inline"
}
```
### Container Actions
```json
{
"containerId": "string",
"containerName": "string",
"action": "start|stop|restart",
"chatId": "number",
"messageId": "number",
"responseMode": "text|inline"
}
```
### Container Logs
```json
{
"containerId": "string (optional if containerName provided)",
"containerName": "string",
"lineCount": "number (default 50, max 1000)",
"chatId": "number",
"messageId": "number",
"responseMode": "text|inline"
}
```
## Troubleshooting
### "Workflow not found" errors
- Verify sub-workflow IDs in main workflow match actual IDs in n8n
- Ensure sub-workflows are set to Active
### Batch operations not working
- Check that "Prepare Batch Update/Action Input" nodes have correct logic
- Verify Execute Batch Update/Action nodes use correct workflow IDs
- Check "Handle Batch Update/Action Result Sub" processes results correctly
### Logs not displaying
- Verify Container Logs workflow ID is correct in main workflow
- Check Docker socket proxy is accessible from n8n
- Verify container names are being normalized correctly
## Future Optimization Opportunities
The current node count (199) is above the target range (120-150). Additional optimization could include:
1. **Consolidate batch confirmation flows** (~15 nodes)
- "Build Batch Commands" path (old execute-all-at-once batch)
- Could be refactored to use the same loop-based approach
2. **Streamline UI/keyboard generation** (~20 nodes)
- Many "Build *" and "Format *" nodes for keyboards
- Could use shared helper sub-workflow
3. **Combine similar routers** (~5-10 nodes)
- Multiple IF/Switch nodes for similar logic
- Could be consolidated
Total potential reduction: ~40-45 nodes → target of ~155-160 nodes
However, these optimizations require deeper refactoring and testing. The current state achieves the primary goals:
- ✓ No duplicate update logic (single + batch use same sub-workflow)
- ✓ No duplicate action logic (single + batch use same sub-workflow)
- ✓ No duplicate logs logic (text + inline use same sub-workflow)
- ✓ All container operations modularized
- ✓ Easier maintenance and debugging
## Notes
- The main workflow still contains batch selection UI, which is intentionally kept in main workflow for better user experience
- Confirmation dialogs remain inline for simplicity
- The old "Build Batch Commands" flow (execute-all-at-once) is separate from the new progress-loop batch execution
- Both batch approaches coexist for backward compatibility
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Refactor n8n workflow to use sub-workflows for batch operations and logs.
Task 1: Wire batch update to Container Update sub-workflow
"""
import json
import copy
import sys
def load_workflow(filename):
with open(filename, 'r') as f:
return json.load(f)
def save_workflow(workflow, filename):
with open(filename, 'w') as f:
json.dump(workflow, f, indent=2)
def find_node_by_name(workflow, name):
for node in workflow['nodes']:
if node['name'] == name:
return node
return None
def remove_node(workflow, node_name):
"""Remove a node and all its connections"""
# Remove from nodes list
workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name]
# Remove from connections (as source)
if node_name in workflow['connections']:
del workflow['connections'][node_name]
# Remove from connections (as target)
for source, outputs in list(workflow['connections'].items()):
for output_key, connections in list(outputs.items()):
workflow['connections'][source][output_key] = [
conn for conn in connections if conn['node'] != node_name
]
# Clean up empty output keys
if not workflow['connections'][source][output_key]:
del workflow['connections'][source][output_key]
# Clean up empty source nodes
if not workflow['connections'][source]:
del workflow['connections'][source]
def create_execute_workflow_node(name, workflow_id, position, parameters=None):
"""Create an Execute Workflow node with proper n8n 1.2 format"""
node = {
"parameters": {
"workflowId": {
"__rl": True,
"mode": "list",
"value": workflow_id
},
"options": {}
},
"id": f"auto-generated-{name.replace(' ', '-').lower()}",
"name": name,
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": position
}
if parameters:
node['parameters'].update(parameters)
return node
def trace_flow_from_node(workflow, start_node_name, max_depth=10):
"""Trace the flow starting from a node"""
flow = []
current = start_node_name
depth = 0
while current and depth < max_depth:
flow.append(current)
# Find next node
if current in workflow['connections']:
outputs = workflow['connections'][current]
if 'main' in outputs and len(outputs['main']) > 0 and len(outputs['main'][0]) > 0:
current = outputs['main'][0][0]['node']
depth += 1
else:
break
else:
break
return flow
def main():
print("Loading workflow...")
workflow = load_workflow('n8n-workflow.json')
print(f"Current node count: {len(workflow['nodes'])}")
# Task 1: Refactor batch update to use Container Update sub-workflow
print("\n=== TASK 1: Wire batch update to Container Update sub-workflow ===")
# Find the batch update flow
batch_prep = find_node_by_name(workflow, 'Prepare Update All Batch')
if not batch_prep:
print("ERROR: Could not find 'Prepare Update All Batch' node")
sys.exit(1)
print(f"Found batch update entry point at position {batch_prep['position']}")
# Trace what happens after batch prep
flow = trace_flow_from_node(workflow, 'Prepare Update All Batch')
print(f"Current batch update flow: {' -> '.join(flow)}")
# The current flow appears to be:
# Prepare Update All Batch -> Send Batch Start Message -> ... (Docker operations)
# We need to replace the Docker operations with a loop that calls the sub-workflow
# However, looking at the node list, I notice "Prepare Update All Batch" is the only
# batch update node. This suggests the batch update might be handled differently.
# Let me check if there's already loop/split logic
# Find all nodes that might be part of batch update
batch_nodes = []
for node in workflow['nodes']:
if 'batch' in node['name'].lower() and 'update' in node['name'].lower():
batch_nodes.append(node['name'])
print(f"\nBatch update related nodes: {batch_nodes}")
# Check if there's already a loop/split node in the flow
loop_found = False
for node_name in flow:
node = find_node_by_name(workflow, node_name)
if node and 'split' in node['type'].lower():
loop_found = True
print(f"Found loop/split node: {node_name}")
if not loop_found:
print("No loop node found - batch update may be using inline iteration")
# Actually, based on the analysis, "Prepare Update All Batch" might already prepare
# data for individual updates. Let me check what Execute Sub-workflow nodes exist
print("\n=== Checking for existing Execute Workflow nodes ===")
exec_nodes = []
for node in workflow['nodes']:
if node['type'] == 'n8n-nodes-base.executeWorkflow':
exec_nodes.append({
'name': node['name'],
'workflow_id': node['parameters'].get('workflowId', {}).get('value', 'N/A')
})
print(f"Found {len(exec_nodes)} Execute Workflow nodes:")
for en in exec_nodes:
print(f" - {en['name']}: {en['workflow_id']}")
# Save analysis for now
print("\nAnalysis complete. Next: implement refactoring...")
# For now, let's save the workflow unchanged and return analysis
print(f"\nFinal node count: {len(workflow['nodes'])}")
return workflow, exec_nodes, flow
if __name__ == '__main__':
workflow, exec_nodes, flow = main()
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""
Task 1: Wire batch update to Container Update sub-workflow
"""
import json
import uuid
# Workflow IDs from STATE.md
CONTAINER_UPDATE_WF_ID = "7AvTzLtKXM2hZTio92_mC"
CONTAINER_ACTIONS_WF_ID = "fYSZS5PkH0VSEaT5"
def load_workflow():
with open('n8n-workflow.json', 'r') as f:
return json.load(f)
def save_workflow(workflow):
with open('n8n-workflow.json', 'w') as f:
json.dump(workflow, f, indent=2)
print(f"Saved workflow with {len(workflow['nodes'])} nodes")
def find_node(workflow, name):
for node in workflow['nodes']:
if node['name'] == name:
return node
return None
def create_execute_workflow_node(name, workflow_id, position):
"""Create an Execute Workflow node with n8n 1.2 format"""
return {
"parameters": {
"workflowId": {
"__rl": True,
"mode": "list",
"value": workflow_id
},
"options": {}
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": position
}
def create_code_node(name, code, position):
"""Create a Code node"""
return {
"parameters": {
"jsCode": code
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": position
}
def main():
print("Loading workflow...")
workflow = load_workflow()
initial_count = len(workflow['nodes'])
print(f"Initial node count: {initial_count}")
# Find Route Batch Loop Action
route_node = find_node(workflow, "Route Batch Loop Action")
if not route_node:
print("ERROR: Could not find Route Batch Loop Action")
return
print(f"\nFound Route Batch Loop Action at {route_node['position']}")
# The Route Batch Loop Action has 4 outputs:
# 0: update (currently empty)
# 1: start
# 2: stop
# 3: restart
# We need to add nodes for the update path
# Position: Route is at [3760, -500], so place new nodes at x=4000+
# 1. Create "Prepare Batch Update Input" code node
# This prepares the input for the sub-workflow
prepare_code = '''// Prepare input for Container Update sub-workflow
const data = $json;
const container = data.container;
// Extract container info
const containerId = container.id || container.Id || '';
const containerName = container.name || container.Name || '';
return {
json: {
containerId: containerId,
containerName: containerName,
chatId: data.chatId,
messageId: data.progressMessageId || 0,
responseMode: "inline"
}
};'''
prepare_node = create_code_node(
"Prepare Batch Update Input",
prepare_code,
[4000, -800]
)
# 2. Create "Execute Batch Update" execute workflow node
execute_node = create_execute_workflow_node(
"Execute Batch Update",
CONTAINER_UPDATE_WF_ID,
[4220, -800]
)
# 3. Create "Handle Batch Update Result" code node
# This processes the result from sub-workflow and prepares for next iteration
handle_code = '''// Handle update result from sub-workflow
const data = $('Build Progress Message').item.json;
const result = $json;
// Update counters based on result
let successCount = data.successCount || 0;
let failureCount = data.failureCount || 0;
let warningCount = data.warningCount || 0;
if (result.success) {
successCount++;
} else {
failureCount++;
}
// Add to results array
const results = data.results || [];
results.push({
container: data.containerName,
action: 'update',
success: result.success,
message: result.message || ''
});
return {
json: {
...data,
successCount: successCount,
failureCount: failureCount,
warningCount: warningCount,
results: results
}
};'''
handle_node = create_code_node(
"Handle Batch Update Result",
handle_code,
[4440, -800]
)
# Add nodes to workflow
print("\nAdding new nodes:")
print(f" - {prepare_node['name']}")
print(f" - {execute_node['name']}")
print(f" - {handle_node['name']}")
workflow['nodes'].extend([prepare_node, execute_node, handle_node])
# Add connections
print("\nAdding connections:")
# Route Batch Loop Action (output 0: update) -> Prepare Batch Update Input
if 'Route Batch Loop Action' not in workflow['connections']:
workflow['connections']['Route Batch Loop Action'] = {'main': [[], [], [], []]}
workflow['connections']['Route Batch Loop Action']['main'][0] = [{
"node": "Prepare Batch Update Input",
"type": "main",
"index": 0
}]
print(" - Route Batch Loop Action [update] -> Prepare Batch Update Input")
# Prepare Batch Update Input -> Execute Batch Update
workflow['connections']['Prepare Batch Update Input'] = {
'main': [[{
"node": "Execute Batch Update",
"type": "main",
"index": 0
}]]
}
print(" - Prepare Batch Update Input -> Execute Batch Update")
# Execute Batch Update -> Handle Batch Update Result
workflow['connections']['Execute Batch Update'] = {
'main': [[{
"node": "Handle Batch Update Result",
"type": "main",
"index": 0
}]]
}
print(" - Execute Batch Update -> Handle Batch Update Result")
# Handle Batch Update Result -> Prepare Next Iteration (same as other actions)
workflow['connections']['Handle Batch Update Result'] = {
'main': [[{
"node": "Prepare Next Iteration",
"type": "main",
"index": 0
}]]
}
print(" - Handle Batch Update Result -> Prepare Next Iteration")
# Save
final_count = len(workflow['nodes'])
print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})")
save_workflow(workflow)
print("\n✓ Task 1 complete: Batch update now uses Container Update sub-workflow")
if __name__ == '__main__':
main()
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""
Task 2: Wire batch actions to Container Actions sub-workflow
"""
import json
import uuid
CONTAINER_ACTIONS_WF_ID = "fYSZS5PkH0VSEaT5"
def load_workflow():
with open('n8n-workflow.json', 'r') as f:
return json.load(f)
def save_workflow(workflow):
with open('n8n-workflow.json', 'w') as f:
json.dump(workflow, f, indent=2)
print(f"Saved workflow with {len(workflow['nodes'])} nodes")
def find_node(workflow, name):
for node in workflow['nodes']:
if node['name'] == name:
return node
return None
def remove_node(workflow, node_name):
"""Remove a node and all its connections"""
# Remove from nodes list
workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name]
# Remove from connections (as source)
if node_name in workflow['connections']:
del workflow['connections'][node_name]
# Remove from connections (as target)
for source, outputs in list(workflow['connections'].items()):
for output_key, connections in list(outputs.items()):
workflow['connections'][source][output_key] = [
[conn for conn in conn_list if conn.get('node') != node_name]
for conn_list in connections
]
def create_execute_workflow_node(name, workflow_id, position):
"""Create an Execute Workflow node with n8n 1.2 format"""
return {
"parameters": {
"workflowId": {
"__rl": True,
"mode": "list",
"value": workflow_id
},
"options": {}
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": position
}
def create_code_node(name, code, position):
"""Create a Code node"""
return {
"parameters": {
"jsCode": code
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": position
}
def main():
print("Loading workflow...")
workflow = load_workflow()
initial_count = len(workflow['nodes'])
print(f"Initial node count: {initial_count}")
# Current batch action flow:
# Route Batch Loop Action (outputs 1,2,3) -> Build Batch Action Command ->
# Execute Batch Container Action -> Check Batch Action Result -> ...
# We need to replace this with:
# Route Batch Loop Action -> Prepare Batch Action Input -> Execute Batch Action ->
# Handle Batch Action Result
# 1. Create "Prepare Batch Action Input" code node
prepare_code = '''// Prepare input for Container Actions sub-workflow
const data = $json;
const container = data.container;
const action = data.action;
// Extract container info
const containerId = container.id || container.Id || '';
const containerName = container.name || container.Name || '';
return {
json: {
containerId: containerId,
containerName: containerName,
action: action,
chatId: data.chatId,
messageId: data.progressMessageId || 0,
responseMode: "inline"
}
};'''
prepare_node = create_code_node(
"Prepare Batch Action Input",
prepare_code,
[4000, -200]
)
# 2. Create "Execute Batch Action" execute workflow node
execute_node = create_execute_workflow_node(
"Execute Batch Action Sub-workflow",
CONTAINER_ACTIONS_WF_ID,
[4220, -200]
)
# 3. Create "Handle Batch Action Result" code node
handle_code = '''// Handle action result from sub-workflow
const data = $('Build Progress Message').item.json;
const result = $json;
// Update counters based on result
let successCount = data.successCount || 0;
let failureCount = data.failureCount || 0;
let warningCount = data.warningCount || 0;
if (result.success) {
successCount++;
} else {
failureCount++;
}
// Add to results array
const results = data.results || [];
results.push({
container: data.containerName,
action: data.action,
success: result.success,
message: result.message || ''
});
return {
json: {
...data,
successCount: successCount,
failureCount: failureCount,
warningCount: warningCount,
results: results
}
};'''
handle_node = create_code_node(
"Handle Batch Action Result Sub",
handle_code,
[4440, -200]
)
# Add nodes to workflow
print("\nAdding new nodes:")
print(f" - {prepare_node['name']}")
print(f" - {execute_node['name']}")
print(f" - {handle_node['name']}")
workflow['nodes'].extend([prepare_node, execute_node, handle_node])
# Update connections from Route Batch Loop Action
# Outputs 1, 2, 3 (start, stop, restart) should go to Prepare Batch Action Input
print("\nUpdating connections:")
for i in [1, 2, 3]:
workflow['connections']['Route Batch Loop Action']['main'][i] = [{
"node": "Prepare Batch Action Input",
"type": "main",
"index": 0
}]
print(" - Route Batch Loop Action [start/stop/restart] -> Prepare Batch Action Input")
# Add new connections
workflow['connections']['Prepare Batch Action Input'] = {
'main': [[{
"node": "Execute Batch Action Sub-workflow",
"type": "main",
"index": 0
}]]
}
print(" - Prepare Batch Action Input -> Execute Batch Action Sub-workflow")
workflow['connections']['Execute Batch Action Sub-workflow'] = {
'main': [[{
"node": "Handle Batch Action Result Sub",
"type": "main",
"index": 0
}]]
}
print(" - Execute Batch Action Sub-workflow -> Handle Batch Action Result Sub")
workflow['connections']['Handle Batch Action Result Sub'] = {
'main': [[{
"node": "Prepare Next Iteration",
"type": "main",
"index": 0
}]]
}
print(" - Handle Batch Action Result Sub -> Prepare Next Iteration")
# Now remove the old nodes that are no longer needed
print("\nRemoving obsolete nodes:")
nodes_to_remove = [
"Build Batch Action Command",
"Execute Batch Container Action",
"Check Batch Action Result",
"Needs Action Call",
"Execute Batch Action 2",
"Parse Batch Action 2",
"Handle Action Result"
]
removed_count = 0
for node_name in nodes_to_remove:
if find_node(workflow, node_name):
print(f" - Removing: {node_name}")
remove_node(workflow, node_name)
removed_count += 1
# Save
final_count = len(workflow['nodes'])
print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})")
print(f"Removed: {removed_count} nodes")
print(f"Added: 3 nodes")
print(f"Net change: {final_count - initial_count:+d} nodes")
save_workflow(workflow)
print("\n✓ Task 2 complete: Batch actions now use Container Actions sub-workflow")
if __name__ == '__main__':
main()
+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")
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""
Task 3 Part 2: Update main workflow to use Container Logs sub-workflow
"""
import json
import uuid
# This will be the ID assigned when we import the workflow to n8n
# For now, use a placeholder - we'll need to update this after import
CONTAINER_LOGS_WF_ID = "PLACEHOLDER_LOGS_ID"
def load_workflow():
with open('n8n-workflow.json', 'r') as f:
return json.load(f)
def save_workflow(workflow):
with open('n8n-workflow.json', 'w') as f:
json.dump(workflow, f, indent=2)
print(f"Saved workflow with {len(workflow['nodes'])} nodes")
def find_node(workflow, name):
for node in workflow['nodes']:
if node['name'] == name:
return node
return None
def remove_node(workflow, node_name):
"""Remove a node and all its connections"""
workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] != node_name]
if node_name in workflow['connections']:
del workflow['connections'][node_name]
for source, outputs in list(workflow['connections'].items()):
for output_key, connections in list(outputs.items()):
workflow['connections'][source][output_key] = [
[conn for conn in conn_list if conn.get('node') != node_name]
for conn_list in connections
]
def create_code_node(name, code, position):
return {
"parameters": {
"jsCode": code
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": position
}
def create_execute_workflow_node(name, workflow_id, position):
return {
"parameters": {
"workflowId": {
"__rl": True,
"mode": "list",
"value": workflow_id
},
"options": {}
},
"id": str(uuid.uuid4()),
"name": name,
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": position
}
def main():
print("Loading workflow...")
workflow = load_workflow()
initial_count = len(workflow['nodes'])
print(f"Initial node count: {initial_count}")
# For TEXT logs command path:
# Current: Keyword Router -> Parse Logs Command -> Docker List for Logs ->
# Match Logs Container -> Check Logs Match Count -> (various paths) ->
# Build Logs Command -> Execute Logs -> Format Logs -> Send Logs Response
#
# New: Keyword Router -> Prepare Text Logs Input -> Execute Logs Sub-workflow ->
# Send Logs Response
# For INLINE logs action path:
# Current: Prepare Logs Action -> Get Container For Logs -> Build Logs Action Command ->
# Execute Logs Action -> Format Logs Action Result -> Send Logs Result
#
# New: Prepare Logs Action -> Execute Logs Sub-workflow -> Send Logs Result
# 1. Create "Prepare Text Logs Input" node
text_input_code = '''// Prepare input for Container Logs sub-workflow (text command)
const data = $json;
// Check if there's an error from Parse Logs Command
if (data.error) {
return {
json: {
error: true,
chatId: data.chatId,
text: data.text
}
};
}
return {
json: {
containerName: data.containerQuery,
lineCount: data.lines,
chatId: data.chatId,
messageId: data.messageId || 0,
responseMode: "text"
}
};'''
text_input_node = create_code_node(
"Prepare Text Logs Input",
text_input_code,
[1120, 600]
)
# 2. Create "Execute Text Logs" sub-workflow node
exec_text_logs = create_execute_workflow_node(
"Execute Text Logs",
CONTAINER_LOGS_WF_ID,
[1340, 600]
)
# 3. Create "Prepare Inline Logs Input" node (renamed from Prepare Logs Action)
inline_input_code = '''// Prepare input for Container Logs sub-workflow (inline action)
const data = $('Parse Callback Data').item.json;
return {
json: {
containerName: data.containerName,
lineCount: 30,
chatId: data.chatId,
messageId: data.messageId,
responseMode: "inline"
}
};'''
inline_input_node = create_code_node(
"Prepare Inline Logs Input",
inline_input_code,
[1780, 1300]
)
# 4. Create "Execute Inline Logs" sub-workflow node
exec_inline_logs = create_execute_workflow_node(
"Execute Inline Logs",
CONTAINER_LOGS_WF_ID,
[2000, 1300]
)
# 5. Create "Format Inline Logs Result" - adds keyboard for inline
inline_format_code = '''// Format logs result for inline keyboard display
const result = $json;
const data = $('Prepare Inline Logs Input').item.json;
// Get container state (need to fetch from Docker)
// For now, build basic keyboard
const containerName = result.containerName;
// Build inline keyboard
const keyboard = [
[
{ text: '🔄 Refresh Logs', callback_data: `action:logs:${containerName}` },
{ text: '⬆️ Update', callback_data: `action:update:${containerName}` }
],
[
{ text: '◀️ Back to List', callback_data: 'list:0' }
]
];
return {
json: {
chatId: data.chatId,
messageId: data.messageId,
text: result.message,
reply_markup: { inline_keyboard: keyboard }
}
};'''
inline_format_node = create_code_node(
"Format Inline Logs Result",
inline_format_code,
[2220, 1300]
)
# Add new nodes
print("\nAdding new nodes:")
workflow['nodes'].extend([
text_input_node,
exec_text_logs,
inline_input_node,
exec_inline_logs,
inline_format_node
])
print(f" - {text_input_node['name']}")
print(f" - {exec_text_logs['name']}")
print(f" - {inline_input_node['name']}")
print(f" - {exec_inline_logs['name']}")
print(f" - {inline_format_node['name']}")
# Update connections
print("\nUpdating connections:")
# Text path: Keyword Router -> Parse Logs Command -> Prepare Text Logs Input
# (Keep Parse Logs Command for error handling)
workflow['connections']['Parse Logs Command'] = {
'main': [[{
"node": "Prepare Text Logs Input",
"type": "main",
"index": 0
}]]
}
print(" - Parse Logs Command -> Prepare Text Logs Input")
# Prepare Text Logs Input -> Execute Text Logs
workflow['connections']['Prepare Text Logs Input'] = {
'main': [[{
"node": "Execute Text Logs",
"type": "main",
"index": 0
}]]
}
print(" - Prepare Text Logs Input -> Execute Text Logs")
# Execute Text Logs -> Send Logs Response
workflow['connections']['Execute Text Logs'] = {
'main': [[{
"node": "Send Logs Response",
"type": "main",
"index": 0
}]]
}
print(" - Execute Text Logs -> Send Logs Response")
# Update Send Logs Response to use result.message
send_logs_node = find_node(workflow, "Send Logs Response")
if send_logs_node and 'parameters' in send_logs_node:
send_logs_node['parameters']['text'] = "={{ $json.message }}"
# Inline path: Action Router -> Prepare Inline Logs Input
# Find what routes to logs action
for source, outputs in workflow['connections'].items():
for output_key, connections in outputs.items():
for i, conn_list in enumerate(connections):
for j, conn in enumerate(conn_list):
if conn.get('node') == 'Prepare Logs Action':
workflow['connections'][source][output_key][i][j]['node'] = 'Prepare Inline Logs Input'
print(f" - {source} -> Prepare Inline Logs Input (was Prepare Logs Action)")
# Prepare Inline Logs Input -> Execute Inline Logs
workflow['connections']['Prepare Inline Logs Input'] = {
'main': [[{
"node": "Execute Inline Logs",
"type": "main",
"index": 0
}]]
}
print(" - Prepare Inline Logs Input -> Execute Inline Logs")
# Execute Inline Logs -> Format Inline Logs Result
workflow['connections']['Execute Inline Logs'] = {
'main': [[{
"node": "Format Inline Logs Result",
"type": "main",
"index": 0
}]]
}
print(" - Execute Inline Logs -> Format Inline Logs Result")
# Format Inline Logs Result -> Send Logs Result
workflow['connections']['Format Inline Logs Result'] = {
'main': [[{
"node": "Send Logs Result",
"type": "main",
"index": 0
}]]
}
print(" - Format Inline Logs Result -> Send Logs Result")
# Remove obsolete nodes
print("\nRemoving obsolete nodes:")
nodes_to_remove = [
"Docker List for Logs",
"Match Logs Container",
"Check Logs Match Count",
"Build Logs Command",
"Execute Logs",
"Format Logs",
"Send Logs Error",
"Format Logs No Match",
"Format Logs Multiple",
"Prepare Logs Action",
"Get Container For Logs",
"Build Logs Action Command",
"Execute Logs Action",
"Format Logs Action Result"
]
removed_count = 0
for node_name in nodes_to_remove:
if find_node(workflow, node_name):
print(f" - Removing: {node_name}")
remove_node(workflow, node_name)
removed_count += 1
# Keep Parse Logs Command for initial parsing and error handling
# Save
final_count = len(workflow['nodes'])
print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})")
print(f"Removed: {removed_count} nodes")
print(f"Added: 5 nodes")
print(f"Net change: {final_count - initial_count:+d} nodes")
save_workflow(workflow)
print("\n✓ Task 3 complete: Logs flow now uses Container Logs sub-workflow")
print("\nNOTE: You must import n8n-container-logs.json to n8n and update")
print(" the CONTAINER_LOGS_WF_ID in this script, then re-run.")
if __name__ == '__main__':
main()
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Task 4: Clean up and verify workflow
"""
import json
def load_workflow():
with open('n8n-workflow.json', 'r') as f:
return json.load(f)
def save_workflow(workflow):
with open('n8n-workflow.json', 'w') as f:
json.dump(workflow, f, indent=2)
print(f"Saved workflow with {len(workflow['nodes'])} nodes")
def find_orphaned_nodes(workflow):
"""Find nodes that are not connected to anything"""
connected_nodes = set()
# Add all nodes that are targets of connections
for source, outputs in workflow['connections'].items():
connected_nodes.add(source) # Source is connected
for output_key, connections in outputs.items():
for conn_list in connections:
for conn in conn_list:
if 'node' in conn:
connected_nodes.add(conn['node'])
# Find trigger node (should always be connected conceptually)
for node in workflow['nodes']:
if 'trigger' in node['type'].lower():
connected_nodes.add(node['name'])
# Find nodes that exist but aren't connected
all_nodes = {node['name'] for node in workflow['nodes']}
orphaned = all_nodes - connected_nodes
return orphaned
def verify_workflow_structure(workflow):
"""Verify workflow has proper structure"""
issues = []
# Check for trigger
has_trigger = any('trigger' in node['type'].lower() for node in workflow['nodes'])
if not has_trigger:
issues.append("WARNING: No trigger node found")
# Check for broken connections (references to non-existent nodes)
all_node_names = {node['name'] for node in workflow['nodes']}
for source, outputs in workflow['connections'].items():
if source not in all_node_names:
issues.append(f"ERROR: Connection source '{source}' does not exist")
for output_key, connections in outputs.items():
for conn_list in connections:
for conn in conn_list:
target = conn.get('node')
if target and target not in all_node_names:
issues.append(f"ERROR: Connection target '{target}' (from {source}) does not exist")
return issues
def analyze_node_types(workflow):
"""Count nodes by type"""
type_counts = {}
for node in workflow['nodes']:
node_type = node['type']
type_counts[node_type] = type_counts.get(node_type, 0) + 1
return type_counts
def main():
print("Loading workflow...")
workflow = load_workflow()
initial_count = len(workflow['nodes'])
print(f"Current node count: {initial_count}")
# Find orphaned nodes
print("\n=== Checking for orphaned nodes ===")
orphaned = find_orphaned_nodes(workflow)
if orphaned:
print(f"Found {len(orphaned)} orphaned nodes:")
for node in orphaned:
print(f" - {node}")
# Option to remove orphaned nodes
print("\nRemoving orphaned nodes...")
workflow['nodes'] = [n for n in workflow['nodes'] if n['name'] not in orphaned]
# Clean up any connections from orphaned nodes
for node in orphaned:
if node in workflow['connections']:
del workflow['connections'][node]
removed_count = len(orphaned)
else:
print("No orphaned nodes found ✓")
removed_count = 0
# Verify structure
print("\n=== Verifying workflow structure ===")
issues = verify_workflow_structure(workflow)
if issues:
print("Issues found:")
for issue in issues:
print(f" - {issue}")
else:
print("Workflow structure is valid ✓")
# Analyze node types
print("\n=== Node composition ===")
type_counts = analyze_node_types(workflow)
for node_type, count in sorted(type_counts.items(), key=lambda x: -x[1]):
short_type = node_type.replace('n8n-nodes-base.', '')
print(f" {count:3d} {short_type}")
# Count Execute Workflow nodes (sub-workflow calls)
exec_wf_count = type_counts.get('n8n-nodes-base.executeWorkflow', 0)
print(f"\n Total Execute Workflow (sub-workflow calls): {exec_wf_count}")
# Save if we made changes
final_count = len(workflow['nodes'])
if removed_count > 0:
print(f"\nNode count: {initial_count} -> {final_count} ({final_count - initial_count:+d})")
save_workflow(workflow)
else:
print(f"\nNo changes made. Final node count: {final_count}")
# Check target
print("\n=== Target Assessment ===")
target_min = 120
target_max = 150
if target_min <= final_count <= target_max:
print(f"✓ Node count {final_count} is within target range ({target_min}-{target_max})")
elif final_count < target_min:
print(f"✓ Node count {final_count} is BELOW target (even better!)")
else:
print(f"⚠ Node count {final_count} is above target range ({target_min}-{target_max})")
print(f" Over target by: {final_count - target_max} nodes")
print("\n✓ Task 4 cleanup complete")
if __name__ == '__main__':
main()