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:
+21
-2
@@ -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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user