1081 lines
38 KiB
Markdown
1081 lines
38 KiB
Markdown
# Phase 15: Infrastructure Foundation - Research
|
|
|
|
**Researched:** 2026-02-09
|
|
**Domain:** Data transformation layers for Unraid GraphQL API integration
|
|
**Confidence:** MEDIUM-HIGH
|
|
|
|
## Summary
|
|
|
|
Phase 15 creates the infrastructure foundation needed to migrate from Docker socket proxy to Unraid's GraphQL API. This phase focuses purely on data transformation layers — no API calls are migrated yet, that happens in Phase 16.
|
|
|
|
The critical challenge is container ID format translation. Docker uses 64-character hex IDs (e.g., `abc123...`), Unraid uses 129-character PrefixedID format (e.g., `server_hash:container_hash`). Phase 14 research revealed actual format from test query, and Phase 15 must build bidirectional translation between container names (workflow contract) and Unraid PrefixedIDs.
|
|
|
|
Telegram's 64-byte callback_data limit becomes more constrained with longer IDs. Current implementation uses base36-encoded bitmaps for batch operations (handles 50+ containers), but single-container callbacks like `action:start:plex` may exceed 64 bytes with 129-char IDs. The solution is hash-based token mapping: store full PrefixedID server-side, encode short token in callback_data.
|
|
|
|
GraphQL response normalization transforms Unraid API shape to match workflow contract. Current Docker API returns `{ Id, Names, State }`, Unraid returns `{ id, names[], state, isUpdateAvailable }`. Normalization layer maps field names, handles array vs string differences, adds missing fields with safe defaults.
|
|
|
|
GraphQL error handling differs from Docker REST API. Successful HTTP 200 can contain `response.errors[]` array. HTTP 304 means "already in desired state" (not an error). Current Docker API checking pattern (`statusCode === 304` for "already started") extends to GraphQL `errors[0].extensions.code === 'ALREADY_IN_STATE'`.
|
|
|
|
Timeout configuration must account for myunraid.net cloud relay latency. Phase 14 research showed 200-500ms overhead vs direct LAN. Current Docker API calls use default n8n timeout (10 seconds). Recommended: 15-second timeout for Unraid API calls, with retry logic for 429 rate limiting.
|
|
|
|
**Primary recommendation:** Build Container ID Registry as centralized translation layer, implement hash-based callback encoding with 8-character tokens, create GraphQL Response Normalizer that matches Docker API contract exactly, standardize error checking with `checkGraphQLErrors(response)` utility function, configure 15-second timeouts on all Unraid API HTTP Request nodes.
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core
|
|
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| n8n Code node | Built-in (1.x) | Data transformation logic | Native JavaScript execution, no external dependencies |
|
|
| n8n HTTP Request node | Built-in (1.x) | GraphQL API calls | Standard HTTP client with configurable timeouts |
|
|
| JavaScript BigInt | ES2020 native | Bitmap encoding for batch callbacks | Handles 50+ containers in 64-byte limit |
|
|
| Base36 encoding | JavaScript native | Compact number representation | Efficient encoding for bitmap values |
|
|
|
|
### Supporting
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| n8n Set node | Built-in | Field mapping/normalization | Simple field renames, add/remove fields |
|
|
| n8n Merge node | Built-in | Combine data from multiple sources | Join container data with translation tables |
|
|
| SHA-256 hash | crypto.subtle Web API | Generate callback tokens | Short stable references to long IDs |
|
|
|
|
### Alternatives Considered
|
|
|
|
| Instead of | Could Use | Tradeoff |
|
|
|------------|-----------|----------|
|
|
| Hash-based token mapping | Protobuf+base85 encoding | Protobuf adds complexity, no significant byte savings for simple callbacks |
|
|
| Centralized ID registry | Per-node ID lookups | Registry provides single source of truth, easier debugging |
|
|
| Code node transformations | n8n expression editor | Code nodes handle complex logic better, more maintainable |
|
|
| 15-second timeout | Default 10-second | Cloud relay adds 200-500ms, 15s provides safety margin |
|
|
|
|
**Installation:**
|
|
|
|
No external dependencies required. All transformation logic uses n8n built-in nodes and native JavaScript features.
|
|
|
|
---
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
|
|
Phase 15 adds infrastructure nodes to existing workflows:
|
|
|
|
```
|
|
Main Workflow (n8n-workflow.json)
|
|
├── Container ID Registry (Code node)
|
|
│ ├── Input: container name
|
|
│ └── Output: { name, dockerId, unraidId }
|
|
├── Callback Token Encoder (Code node)
|
|
│ ├── Input: unraidId
|
|
│ └── Output: 8-char token
|
|
└── Callback Token Decoder (Code node)
|
|
├── Input: 8-char token
|
|
└── Output: unraidId
|
|
|
|
GraphQL Response Normalizer (reusable utility)
|
|
├── Input: Unraid API response
|
|
└── Output: Docker API-compatible contract
|
|
```
|
|
|
|
### Pattern 1: Container ID Translation Registry
|
|
|
|
**What:** Centralized mapping between container names, Docker IDs, and Unraid PrefixedIDs
|
|
|
|
**When to use:** Every workflow node that handles container identification
|
|
|
|
**Example:**
|
|
|
|
```javascript
|
|
// Source: Project research, bitmap encoding pattern from n8n-batch-ui.json
|
|
// Container ID Registry (Code node)
|
|
|
|
// Static mapping built from container list query
|
|
// In production, this would be populated from "List Containers" nodes
|
|
const registry = $getWorkflowStaticData('global');
|
|
|
|
// Initialize registry if empty
|
|
if (!registry._containerIdMap) {
|
|
registry._containerIdMap = JSON.stringify({});
|
|
}
|
|
|
|
const containerMap = JSON.parse(registry._containerIdMap);
|
|
|
|
// Update registry with new container data
|
|
function updateRegistry(containers) {
|
|
const newMap = {};
|
|
|
|
for (const container of containers) {
|
|
const name = container.names?.[0] || container.Names?.[0];
|
|
const cleanName = name.replace(/^\//, '').toLowerCase();
|
|
|
|
newMap[cleanName] = {
|
|
name: cleanName,
|
|
dockerId: container.Id || container.id,
|
|
unraidId: container.id // Unraid PrefixedID format
|
|
};
|
|
}
|
|
|
|
registry._containerIdMap = JSON.stringify(newMap);
|
|
return newMap;
|
|
}
|
|
|
|
// Lookup by name
|
|
function getUnraidId(containerName) {
|
|
const cleanName = containerName.replace(/^\//, '').toLowerCase();
|
|
const entry = containerMap[cleanName];
|
|
|
|
if (!entry) {
|
|
throw new Error(`Container not found in registry: ${containerName}`);
|
|
}
|
|
|
|
return entry.unraidId;
|
|
}
|
|
|
|
// Example usage: translate name to Unraid ID
|
|
const inputName = $input.item.json.containerName;
|
|
const unraidId = getUnraidId(inputName);
|
|
|
|
return {
|
|
json: {
|
|
containerName: inputName,
|
|
unraidId: unraidId
|
|
}
|
|
};
|
|
```
|
|
|
|
**Why this pattern:** Single source of truth for ID mapping, survives across workflow executions via static data, handles both Docker and Unraid formats.
|
|
|
|
### Pattern 2: Hash-Based Callback Token Encoding
|
|
|
|
**What:** Encode long Unraid PrefixedIDs as short tokens for Telegram callback_data
|
|
|
|
**When to use:** All inline keyboard callbacks that include container IDs
|
|
|
|
**Example:**
|
|
|
|
```javascript
|
|
// Source: Telegram callback_data 64-byte limit research
|
|
// Callback Token Encoder (Code node)
|
|
|
|
const staticData = $getWorkflowStaticData('global');
|
|
|
|
// Initialize token store
|
|
if (!staticData._callbackTokens) {
|
|
staticData._callbackTokens = JSON.stringify({});
|
|
}
|
|
|
|
const tokenStore = JSON.parse(staticData._callbackTokens);
|
|
|
|
// Generate 8-character token from container ID
|
|
async function encodeToken(unraidId) {
|
|
// Use first 8 chars of SHA-256 hash as stable token
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(unraidId);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
const token = hashHex.substring(0, 8);
|
|
|
|
// Store mapping
|
|
tokenStore[token] = unraidId;
|
|
staticData._callbackTokens = JSON.stringify(tokenStore);
|
|
|
|
return token;
|
|
}
|
|
|
|
// Example: action:start:plex (Docker) → action:start:a1b2c3d4 (Unraid)
|
|
const action = $input.item.json.action; // e.g., "start"
|
|
const unraidId = $input.item.json.unraidId; // e.g., "abc123:def456"
|
|
|
|
const token = await encodeToken(unraidId);
|
|
const callbackData = `action:${action}:${token}`;
|
|
|
|
return {
|
|
json: {
|
|
action: action,
|
|
unraidId: unraidId,
|
|
token: token,
|
|
callbackData: callbackData,
|
|
byteSize: new TextEncoder().encode(callbackData).length
|
|
}
|
|
};
|
|
```
|
|
|
|
**Callback Token Decoder (reverse operation):**
|
|
|
|
```javascript
|
|
// Source: Project pattern
|
|
// Callback Token Decoder (Code node)
|
|
|
|
const staticData = $getWorkflowStaticData('global');
|
|
const tokenStore = JSON.parse(staticData._callbackTokens || '{}');
|
|
|
|
function decodeToken(token) {
|
|
const unraidId = tokenStore[token];
|
|
|
|
if (!unraidId) {
|
|
throw new Error(`Token not found in registry: ${token}`);
|
|
}
|
|
|
|
return unraidId;
|
|
}
|
|
|
|
// Parse callback like "action:start:a1b2c3d4"
|
|
const callbackData = $input.item.json.callbackData;
|
|
const parts = callbackData.split(':');
|
|
const action = parts[1];
|
|
const token = parts[2];
|
|
|
|
const unraidId = decodeToken(token);
|
|
|
|
return {
|
|
json: {
|
|
action: action,
|
|
token: token,
|
|
unraidId: unraidId
|
|
}
|
|
};
|
|
```
|
|
|
|
**Why this pattern:** 8-char tokens vs 129-char PrefixedIDs saves ~120 bytes, stable hashing ensures same ID always gets same token, fits within 64-byte limit even with action prefix.
|
|
|
|
### Pattern 3: GraphQL Response Normalization
|
|
|
|
**What:** Transform Unraid GraphQL API responses to match Docker API contract
|
|
|
|
**When to use:** After every Unraid API query, before passing data to existing workflow nodes
|
|
|
|
**Example:**
|
|
|
|
```javascript
|
|
// Source: Phase 14 research, current Docker API contract
|
|
// GraphQL Response Normalizer (Code node)
|
|
|
|
function normalizeContainers(graphqlResponse) {
|
|
// Check for GraphQL errors first
|
|
if (graphqlResponse.errors) {
|
|
const errorMsg = graphqlResponse.errors.map(e => e.message).join(', ');
|
|
throw new Error(`Unraid API error: ${errorMsg}`);
|
|
}
|
|
|
|
if (!graphqlResponse.data?.docker?.containers) {
|
|
throw new Error('Invalid GraphQL response structure');
|
|
}
|
|
|
|
const unraidContainers = graphqlResponse.data.docker.containers;
|
|
|
|
// Transform to Docker API contract
|
|
const dockerFormat = unraidContainers.map(c => ({
|
|
// Map Unraid fields to Docker fields
|
|
Id: c.id, // Keep Unraid PrefixedID (translation happens in registry)
|
|
Names: c.names.map(n => '/' + n), // Docker adds leading slash
|
|
State: c.state.toLowerCase(), // Unraid: "running", Docker: "running"
|
|
Status: c.state, // Docker has separate Status field
|
|
Image: c.image || '', // Add if available
|
|
|
|
// Add Unraid-specific fields for update detection
|
|
UpdateAvailable: c.isUpdateAvailable || false,
|
|
|
|
// Preserve original Unraid data for debugging
|
|
_unraidOriginal: c
|
|
}));
|
|
|
|
return dockerFormat;
|
|
}
|
|
|
|
// Example usage
|
|
const graphqlResponse = $input.item.json;
|
|
const normalized = normalizeContainers(graphqlResponse);
|
|
|
|
return normalized.map(c => ({ json: c }));
|
|
```
|
|
|
|
**Why this pattern:** Preserves existing workflow logic (no changes to 60+ Code nodes), adds Unraid features (isUpdateAvailable) alongside Docker contract, includes original data for debugging.
|
|
|
|
### Pattern 4: Standardized GraphQL Error Handling
|
|
|
|
**What:** Consistent error checking across all Unraid API calls
|
|
|
|
**When to use:** Every HTTP Request node that calls Unraid GraphQL API
|
|
|
|
**Example:**
|
|
|
|
```javascript
|
|
// Source: GraphQL error handling research, current Docker API pattern
|
|
// Check GraphQL Errors (Code node - place after every Unraid API call)
|
|
|
|
const response = $input.item.json;
|
|
|
|
// Utility function for error checking
|
|
function checkGraphQLErrors(response) {
|
|
// Check for GraphQL-level errors
|
|
if (response.errors && response.errors.length > 0) {
|
|
const error = response.errors[0];
|
|
const code = error.extensions?.code;
|
|
const message = error.message;
|
|
|
|
// HTTP 304 equivalent: already in desired state
|
|
if (code === 'ALREADY_IN_STATE') {
|
|
return {
|
|
alreadyInState: true,
|
|
message: message
|
|
};
|
|
}
|
|
|
|
// Permission errors
|
|
if (code === 'FORBIDDEN' || code === 'UNAUTHORIZED') {
|
|
throw new Error(`Permission denied: ${message}. Check API key permissions.`);
|
|
}
|
|
|
|
// Container not found
|
|
if (code === 'NOT_FOUND') {
|
|
throw new Error(`Container not found: ${message}`);
|
|
}
|
|
|
|
// Generic error
|
|
throw new Error(`Unraid API error: ${message}`);
|
|
}
|
|
|
|
// Check for HTTP-level errors
|
|
if (response.statusCode && response.statusCode >= 400) {
|
|
throw new Error(`HTTP ${response.statusCode}: ${response.statusMessage || 'Unknown error'}`);
|
|
}
|
|
|
|
// Check for missing data
|
|
if (!response.data) {
|
|
throw new Error('GraphQL response missing data field');
|
|
}
|
|
|
|
return {
|
|
alreadyInState: false,
|
|
data: response.data
|
|
};
|
|
}
|
|
|
|
// Example usage
|
|
const result = checkGraphQLErrors(response);
|
|
|
|
if (result.alreadyInState) {
|
|
// Handle HTTP 304 equivalent
|
|
return {
|
|
json: {
|
|
success: true,
|
|
message: 'Container already in desired state',
|
|
statusCode: 304
|
|
}
|
|
};
|
|
}
|
|
|
|
// Pass through normalized data
|
|
return {
|
|
json: {
|
|
success: true,
|
|
data: result.data
|
|
}
|
|
};
|
|
```
|
|
|
|
**Why this pattern:** Mirrors existing Docker API error checking (`statusCode === 304`), handles GraphQL-specific error array, provides clear error messages for debugging.
|
|
|
|
### Pattern 5: Timeout Configuration for Cloud Relay
|
|
|
|
**What:** Configure appropriate timeouts for myunraid.net cloud relay latency
|
|
|
|
**When to use:** All HTTP Request nodes calling Unraid API
|
|
|
|
**Configuration:**
|
|
|
|
```javascript
|
|
// HTTP Request node settings for Unraid API calls
|
|
{
|
|
"url": "={{ $env.UNRAID_HOST }}/graphql",
|
|
"method": "POST",
|
|
"authentication": "headerAuth",
|
|
"sendHeaders": true,
|
|
"headerParameters": {
|
|
"parameters": [
|
|
{
|
|
"name": "Content-Type",
|
|
"value": "application/json"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"timeout": 15000, // 15 seconds (accounts for cloud relay latency)
|
|
"retry": {
|
|
"enabled": true,
|
|
"maxRetries": 2,
|
|
"waitBetweenRetries": 1000 // 1 second between retries
|
|
}
|
|
},
|
|
"onError": "continueRegularOutput" // Handle errors in Code node
|
|
}
|
|
```
|
|
|
|
**Why these values:**
|
|
- **15 seconds:** Cloud relay adds 200-500ms per request, safety margin for slow responses
|
|
- **2 retries:** Handles transient network issues with myunraid.net
|
|
- **1 second wait:** Rate limiting consideration (Unraid API has limits, threshold unknown)
|
|
- **continueRegularOutput:** Allows Code node to check errors (matches Docker API pattern)
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **Inline ID translation in every node:** Use centralized registry, not scattered lookups
|
|
- **Storing full PrefixedIDs in callback_data:** Use 8-char tokens, not 129-char IDs
|
|
- **Assuming successful HTTP 200 means no errors:** Always check `response.errors[]` array
|
|
- **Using Docker API timeout values:** Cloud relay is slower, increase timeouts appropriately
|
|
- **Hardcoding container ID format:** Use registry abstraction, format may change in future Unraid versions
|
|
|
|
---
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Callback data compression | Custom compression algorithm (gzip, LZ4) | Hash-based token mapping | Compression adds encode/decode overhead, hashes are instant |
|
|
| GraphQL client library | Custom schema parser, type validator | n8n HTTP Request with JSON body | GraphQL-over-HTTP is standard, no client library needed |
|
|
| Container ID cache persistence | External database (Redis, SQLite) | n8n static data with JSON serialization | Built-in persistence, no external dependencies |
|
|
| Error code mapping | Large switch statement | Utility function with error code constants | Centralized logic, easier to maintain |
|
|
|
|
**Key insight:** n8n provides sufficient primitives (static data, Code nodes, HTTP Request). Building external infrastructure adds operational complexity without benefit for this use case.
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Static Data Deep Mutation Not Persisting
|
|
|
|
**What goes wrong:** Update nested object in static data, changes disappear after workflow execution.
|
|
|
|
**Why it happens:** n8n only persists **top-level** property changes to `$getWorkflowStaticData('global')`. Deep mutations (e.g., `staticData.registry.plex = { ... }`) are silently lost.
|
|
|
|
**How to avoid:** Always use JSON serialization pattern:
|
|
|
|
```javascript
|
|
// WRONG - deep mutation not persisted
|
|
const registry = $getWorkflowStaticData('global');
|
|
registry.containers = registry.containers || {};
|
|
registry.containers.plex = { id: 'abc:def' }; // Lost after execution!
|
|
|
|
// CORRECT - top-level assignment persisted
|
|
const registry = $getWorkflowStaticData('global');
|
|
const containers = JSON.parse(registry._containers || '{}');
|
|
containers.plex = { id: 'abc:def' };
|
|
registry._containers = JSON.stringify(containers); // Persisted!
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Container ID registry resets to empty after workflow execution
|
|
- Token mappings disappear between callback queries
|
|
- "Token not found" errors despite recent encoding
|
|
|
|
**Source:** Project `CLAUDE.md` static data persistence pattern
|
|
|
|
### Pitfall 2: Callback Token Collisions with Short Hashes
|
|
|
|
**What goes wrong:** Two different Unraid PrefixedIDs hash to same 8-character token, callback decoding returns wrong container.
|
|
|
|
**Why it happens:** Birthday paradox — with 50 containers and 8-hex-char tokens (2^32 space), collision probability is ~0.06%.
|
|
|
|
**How to avoid:**
|
|
1. **Use full SHA-256 hash (64 chars)** for token generation, take first 8 chars
|
|
2. **Check for collisions** when storing tokens, increment suffix if collision detected
|
|
3. **Log token generation** for debugging
|
|
|
|
```javascript
|
|
// Collision detection pattern
|
|
async function encodeToken(unraidId) {
|
|
const tokenStore = JSON.parse(staticData._callbackTokens || '{}');
|
|
|
|
// Generate base token
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId));
|
|
const hashHex = Array.from(new Uint8Array(hashBuffer))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
|
|
// Try first 8 chars
|
|
let token = hashHex.substring(0, 8);
|
|
let suffix = 0;
|
|
|
|
// Check for collision
|
|
while (tokenStore[token] && tokenStore[token] !== unraidId) {
|
|
// Collision detected - try next 8 chars
|
|
const start = 8 + (suffix * 8);
|
|
token = hashHex.substring(start, start + 8);
|
|
suffix++;
|
|
|
|
if (start + 8 > hashHex.length) {
|
|
// Ran out of hash space - very unlikely
|
|
throw new Error('Token collision - hash exhausted');
|
|
}
|
|
}
|
|
|
|
tokenStore[token] = unraidId;
|
|
staticData._callbackTokens = JSON.stringify(tokenStore);
|
|
|
|
return token;
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Wrong container triggered when clicking action button
|
|
- "Container not found" errors for valid containers
|
|
- Token decode returns different ID than encoded
|
|
|
|
**Impact:** LOW (collision probability <0.1% with 50 containers), but consequences are HIGH (wrong container action = data loss risk)
|
|
|
|
**Source:** [Enhanced Telegram callback_data with protobuf + base85](https://seroperson.me/2025/02/05/enhanced-telegram-callback-data/)
|
|
|
|
### Pitfall 3: GraphQL Response Shape Mismatch
|
|
|
|
**What goes wrong:** Workflow Code node expects `container.State` (Docker), gets `container.state` (Unraid), undefined field causes errors.
|
|
|
|
**Why it happens:** GraphQL returns different field names and types than Docker REST API. `Names` vs `names[]`, `State` vs `state`, missing fields like `Status`.
|
|
|
|
**How to avoid:** Use normalization layer BEFORE passing data to existing nodes:
|
|
|
|
```javascript
|
|
// Bad: Pass Unraid response directly
|
|
const unraidContainers = response.data.docker.containers;
|
|
return unraidContainers; // Breaks downstream nodes expecting Docker format
|
|
|
|
// Good: Normalize first
|
|
const normalized = normalizeContainers(response);
|
|
return normalized; // Matches Docker contract exactly
|
|
```
|
|
|
|
**Verification pattern:**
|
|
```javascript
|
|
// Test normalization output
|
|
const sample = normalized[0];
|
|
console.log({
|
|
hasId: 'Id' in sample, // Must be true
|
|
hasNames: 'Names' in sample && Array.isArray(sample.Names), // Must be true
|
|
hasState: 'State' in sample && typeof sample.State === 'string', // Must be true
|
|
namesHaveSlash: sample.Names[0].startsWith('/') // Must be true (Docker format)
|
|
});
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Workflow errors: "Cannot read property 'State' of undefined"
|
|
- Container names missing leading slash (Docker uses `/plex`, Unraid uses `plex`)
|
|
- State detection fails (running containers show as stopped)
|
|
|
|
**Source:** Phase 14 research Unraid GraphQL schema, current Docker API contract in workflow code
|
|
|
|
### Pitfall 4: Timeout Too Short for Cloud Relay Latency
|
|
|
|
**What goes wrong:** HTTP Request to Unraid API times out with "Request timed out after 10000ms", API call succeeded but response didn't arrive in time.
|
|
|
|
**Why it happens:** myunraid.net cloud relay adds 200-500ms latency vs direct LAN. Default n8n timeout (10 seconds) doesn't account for relay overhead. Mutation operations (start/stop/update) can take 3-5 seconds plus relay latency.
|
|
|
|
**How to avoid:**
|
|
1. **Set timeout to 15 seconds** in HTTP Request node options
|
|
2. **Enable retry logic** for transient network failures
|
|
3. **Test with worst-case latency** (remote access over cellular network)
|
|
|
|
```javascript
|
|
// HTTP Request node configuration
|
|
{
|
|
"options": {
|
|
"timeout": 15000, // 15 seconds
|
|
"retry": {
|
|
"enabled": true,
|
|
"maxRetries": 2
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Timeouts during container updates (slow operations)
|
|
- Intermittent failures on same API call (network variance)
|
|
- Success when running workflow manually (lower latency), failure in production
|
|
|
|
**Testing recommendation:** Add artificial delay to simulate high latency:
|
|
|
|
```javascript
|
|
// Test timeout handling with forced delay
|
|
const response = await fetch(url, { signal: AbortSignal.timeout(15000) });
|
|
```
|
|
|
|
**Source:** [n8n HTTP Request timeout configuration](https://docs.n8n.io/hosting/configuration/configuration-examples/execution-timeout/), Phase 14 research myunraid.net latency
|
|
|
|
### Pitfall 5: Container Registry Stale Data
|
|
|
|
**What goes wrong:** User adds new container in Unraid WebGUI, Telegram bot shows "Container not found" because registry wasn't updated.
|
|
|
|
**Why it happens:** Container ID registry is populated once during workflow initialization. New containers added outside the bot aren't automatically detected.
|
|
|
|
**How to avoid:**
|
|
1. **Refresh registry on every "list" or "status" command** (rebuilds from current state)
|
|
2. **Add timestamp to registry** to detect staleness
|
|
3. **Handle "not found" gracefully** with helpful message
|
|
|
|
```javascript
|
|
// Registry refresh pattern
|
|
function refreshRegistry(containers) {
|
|
const registry = $getWorkflowStaticData('global');
|
|
const now = Date.now();
|
|
|
|
const newMap = {};
|
|
for (const c of containers) {
|
|
const name = c.names[0].replace(/^\//, '').toLowerCase();
|
|
newMap[name] = {
|
|
name: name,
|
|
dockerId: c.Id,
|
|
unraidId: c.id,
|
|
lastSeen: now
|
|
};
|
|
}
|
|
|
|
registry._containerIdMap = JSON.stringify(newMap);
|
|
registry._lastRefresh = now;
|
|
|
|
return newMap;
|
|
}
|
|
|
|
// Graceful fallback for missing containers
|
|
function getUnraidId(containerName) {
|
|
const registry = $getWorkflowStaticData('global');
|
|
const map = JSON.parse(registry._containerIdMap || '{}');
|
|
const lastRefresh = registry._lastRefresh || 0;
|
|
const age = Date.now() - lastRefresh;
|
|
|
|
const entry = map[containerName];
|
|
|
|
if (!entry) {
|
|
if (age > 60000) { // Registry older than 1 minute
|
|
throw new Error(`Container "${containerName}" not found. Registry may be stale - try "status" to refresh.`);
|
|
} else {
|
|
throw new Error(`Container "${containerName}" not found. Check spelling or run "status" to see all containers.`);
|
|
}
|
|
}
|
|
|
|
return entry.unraidId;
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- "Not found" errors for containers that exist in Unraid
|
|
- Registry size doesn't match container count
|
|
- Errors after adding/removing containers in WebGUI
|
|
|
|
**Mitigation:** Auto-refresh on common commands (status, list, batch), add manual "/refresh" command for edge cases.
|
|
|
|
**Source:** Project pattern, existing container matching workflow
|
|
|
|
### Pitfall 6: Unraid API Rate Limiting on Batch Operations
|
|
|
|
**What goes wrong:** Batch update of 20 containers triggers rate limiting, API returns 429 Too Many Requests after 5th container.
|
|
|
|
**Why it happens:** Unraid API implements rate limiting (confirmed in docs, thresholds unknown). Batch operations may trigger limits if implemented as N individual API calls.
|
|
|
|
**How to avoid:**
|
|
1. **Use batch mutations** (`updateContainers` plural) instead of N individual calls
|
|
2. **Implement exponential backoff** for 429 responses
|
|
3. **Sequence operations** instead of parallel (reduces burst load)
|
|
|
|
```javascript
|
|
// Batch mutation pattern (GraphQL supports multiple IDs)
|
|
mutation UpdateMultipleContainers($ids: [PrefixedID!]!) {
|
|
docker {
|
|
updateContainers(ids: $ids) {
|
|
id
|
|
state
|
|
isUpdateAvailable
|
|
}
|
|
}
|
|
}
|
|
|
|
// Exponential backoff for rate limiting
|
|
async function callWithRetry(apiCall, maxRetries = 3) {
|
|
for (let i = 0; i < maxRetries; i++) {
|
|
const response = await apiCall();
|
|
|
|
if (response.statusCode === 429) {
|
|
const delay = Math.pow(2, i) * 1000; // 1s, 2s, 4s
|
|
console.log(`Rate limited, retrying in ${delay}ms`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
continue;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
throw new Error('Rate limit exceeded after retries');
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- HTTP 429 responses during batch operations
|
|
- GraphQL error: "rate limit exceeded"
|
|
- Partial batch completion (first N containers succeed, rest fail)
|
|
|
|
**Impact assessment:** LOW for this project (bot updates are infrequent, user-initiated). Rate limiting unlikely in normal usage but must handle gracefully.
|
|
|
|
**Source:** [Using Unraid API](https://docs.unraid.net/API/how-to-use-the-api/), Phase 14 research
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
Verified patterns from official sources and project implementation:
|
|
|
|
### Container ID Translation Flow (End-to-End)
|
|
|
|
```javascript
|
|
// Source: Project bitmap encoding pattern, Phase 14 container ID format
|
|
// Complete flow: User action → Container name → Unraid ID → Token → Callback
|
|
|
|
// Step 1: User sends "start plex"
|
|
// Parse Container Name node
|
|
const input = $input.item.json.text; // "start plex"
|
|
const parts = input.trim().split(/\s+/);
|
|
const action = parts[0].toLowerCase(); // "start"
|
|
const containerName = parts.slice(1).join(' ').toLowerCase(); // "plex"
|
|
|
|
// Step 2: Translate name to Unraid ID
|
|
// Container ID Registry Lookup node
|
|
const registry = $getWorkflowStaticData('global');
|
|
const containerMap = JSON.parse(registry._containerIdMap || '{}');
|
|
|
|
const entry = containerMap[containerName];
|
|
if (!entry) {
|
|
throw new Error(`Container not found: ${containerName}`);
|
|
}
|
|
|
|
const unraidId = entry.unraidId; // e.g., "abc123...def456:container_hash"
|
|
|
|
// Step 3: Generate callback token
|
|
// Callback Token Encoder node
|
|
const tokenStore = JSON.parse(registry._callbackTokens || '{}');
|
|
|
|
async function encodeToken(unraidId) {
|
|
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(unraidId));
|
|
const hex = Array.from(new Uint8Array(hash))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
return hex.substring(0, 8);
|
|
}
|
|
|
|
const token = await encodeToken(unraidId);
|
|
tokenStore[token] = unraidId;
|
|
registry._callbackTokens = JSON.stringify(tokenStore);
|
|
|
|
// Step 4: Build callback data
|
|
const callbackData = `action:${action}:${token}`; // "action:start:a1b2c3d4"
|
|
const byteSize = new TextEncoder().encode(callbackData).length;
|
|
|
|
if (byteSize > 64) {
|
|
throw new Error(`Callback data too large: ${byteSize} bytes`);
|
|
}
|
|
|
|
// Step 5: User clicks button, decode token
|
|
// Parse Callback Data node (triggered by Telegram)
|
|
const callback = $input.item.json.data; // "action:start:a1b2c3d4"
|
|
const [prefix, cbAction, cbToken] = callback.split(':');
|
|
|
|
const decodedId = tokenStore[cbToken];
|
|
if (!decodedId) {
|
|
throw new Error(`Invalid callback token: ${cbToken}`);
|
|
}
|
|
|
|
// Step 6: Call Unraid API with PrefixedID
|
|
// HTTP Request node
|
|
const graphqlQuery = {
|
|
query: `
|
|
mutation StartContainer($id: PrefixedID!) {
|
|
docker {
|
|
startContainer(id: $id) {
|
|
id
|
|
state
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: {
|
|
id: decodedId // Full Unraid PrefixedID format
|
|
}
|
|
};
|
|
|
|
// Return for HTTP Request node
|
|
return {
|
|
json: {
|
|
query: graphqlQuery.query,
|
|
variables: graphqlQuery.variables
|
|
}
|
|
};
|
|
```
|
|
|
|
### GraphQL Error Handling with HTTP 304 Equivalent
|
|
|
|
```javascript
|
|
// Source: GraphQL error handling research, current Docker API pattern
|
|
// Format Action Result node (after Unraid API call)
|
|
|
|
const response = $input.item.json;
|
|
const routeData = $('Route Action').item.json;
|
|
const containerName = routeData.containerName;
|
|
const action = routeData.action;
|
|
|
|
// Check GraphQL errors array
|
|
function checkGraphQLErrors(response) {
|
|
if (response.errors && response.errors.length > 0) {
|
|
const error = response.errors[0];
|
|
const code = error.extensions?.code;
|
|
const message = error.message;
|
|
|
|
// Map GraphQL error codes to HTTP status codes
|
|
const errorMap = {
|
|
'ALREADY_IN_STATE': 304, // HTTP 304 Not Modified equivalent
|
|
'NOT_FOUND': 404,
|
|
'FORBIDDEN': 403,
|
|
'UNAUTHORIZED': 401
|
|
};
|
|
|
|
const statusCode = errorMap[code] || 500;
|
|
|
|
return {
|
|
statusCode: statusCode,
|
|
message: message,
|
|
isError: statusCode >= 400
|
|
};
|
|
}
|
|
|
|
// Check HTTP-level errors
|
|
if (response.statusCode && response.statusCode >= 400) {
|
|
return {
|
|
statusCode: response.statusCode,
|
|
message: response.statusMessage || 'Unknown error',
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
return {
|
|
statusCode: 200,
|
|
message: 'Success',
|
|
isError: false
|
|
};
|
|
}
|
|
|
|
const result = checkGraphQLErrors(response);
|
|
|
|
// Handle HTTP 304 equivalent (matches current Docker API pattern)
|
|
if (result.statusCode === 304) {
|
|
const messages = {
|
|
start: `is already started`,
|
|
stop: `is already stopped`,
|
|
restart: `was restarted`
|
|
};
|
|
|
|
return {
|
|
json: {
|
|
success: true,
|
|
message: `✅ <b>${containerName}</b> ${messages[action] || 'is already in desired state'}`,
|
|
statusCode: 304
|
|
}
|
|
};
|
|
}
|
|
|
|
// Handle errors
|
|
if (result.isError) {
|
|
return {
|
|
json: {
|
|
success: false,
|
|
message: `❌ Failed to ${action} <b>${containerName}</b>: ${result.message}`,
|
|
statusCode: result.statusCode
|
|
}
|
|
};
|
|
}
|
|
|
|
// Success
|
|
return {
|
|
json: {
|
|
success: true,
|
|
message: `✅ <b>${containerName}</b> ${action} successful`,
|
|
data: response.data
|
|
}
|
|
};
|
|
```
|
|
|
|
**Source:** [GraphQL Error Handling](https://graphql.org/learn/debug-errors/), [Apollo Server Error Handling](https://www.apollographql.com/docs/apollo-server/data/errors), current Docker API pattern in `n8n-actions.json`
|
|
|
|
### Bitmap Encoding with Unraid IDs
|
|
|
|
```javascript
|
|
// Source: Project n8n-batch-ui.json, modified for Unraid ID support
|
|
// Build Batch Keyboard node (with Unraid ID registry integration)
|
|
|
|
// Bitmap helpers (unchanged from current implementation)
|
|
function encodeBitmap(selectedIndices) {
|
|
let bitmap = 0n;
|
|
for (const idx of selectedIndices) {
|
|
bitmap |= (1n << BigInt(idx));
|
|
}
|
|
return bitmap.toString(36);
|
|
}
|
|
|
|
function decodeBitmap(b36) {
|
|
if (!b36 || b36 === '0') return new Set();
|
|
let val = 0n;
|
|
for (const ch of b36) {
|
|
val = val * 36n + BigInt(parseInt(ch, 36));
|
|
}
|
|
const indices = new Set();
|
|
let i = 0;
|
|
let v = val;
|
|
while (v > 0n) {
|
|
if (v & 1n) indices.add(i);
|
|
v >>= 1n;
|
|
i++;
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
// NEW: Maintain parallel array of Unraid IDs
|
|
const containers = $input.all();
|
|
const registry = $getWorkflowStaticData('global');
|
|
|
|
// Build index → Unraid ID mapping
|
|
const indexToId = [];
|
|
for (const item of containers) {
|
|
const c = item.json;
|
|
const name = c.Names[0].replace(/^\//, '').toLowerCase();
|
|
|
|
// Look up Unraid ID from registry
|
|
const containerMap = JSON.parse(registry._containerIdMap || '{}');
|
|
const entry = containerMap[name];
|
|
|
|
if (entry) {
|
|
indexToId.push(entry.unraidId);
|
|
} else {
|
|
// Fallback: container not in registry (shouldn't happen after refresh)
|
|
console.warn(`Container ${name} not in registry`);
|
|
indexToId.push(null);
|
|
}
|
|
}
|
|
|
|
// Store index mapping in static data (needed for exec step)
|
|
registry._batchIndexMap = JSON.stringify(indexToId);
|
|
|
|
// Bitmap encoding unchanged (still uses indices, not IDs)
|
|
const page = $input.item.json.batchPage || 0;
|
|
const bitmap = $input.item.json.bitmap || '0';
|
|
const selected = decodeBitmap(bitmap);
|
|
|
|
// Build keyboard (existing logic)
|
|
// ... keyboard building code unchanged ...
|
|
|
|
// When user clicks "Execute", decode bitmap to Unraid IDs
|
|
// Handle Exec node
|
|
const execData = $('Handle Exec').item.json;
|
|
const execBitmap = execData.bitmap;
|
|
const selectedIndices = decodeBitmap(execBitmap);
|
|
|
|
const indexMap = JSON.parse(registry._batchIndexMap || '[]');
|
|
const selectedIds = Array.from(selectedIndices)
|
|
.map(idx => indexMap[idx])
|
|
.filter(id => id !== null); // Skip missing entries
|
|
|
|
return {
|
|
json: {
|
|
action: execData.action,
|
|
containerIds: selectedIds, // Array of Unraid PrefixedIDs
|
|
count: selectedIds.length
|
|
}
|
|
};
|
|
```
|
|
|
|
**Why this works:** Bitmap encoding is index-based (unchanged), but index → Unraid ID mapping stored separately. Callback data size stays same (bitmap is compact), ID translation happens server-side during decode.
|
|
|
|
---
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| Store full container data in callback | Hash-based token mapping | 2025 (Telegram callback_data research) | Saves 100+ bytes per callback, enables longer IDs |
|
|
| Direct API response pass-through | Normalization layer | Common pattern in GraphQL integrations | Decouples API contract from workflow logic |
|
|
| Fixed 10-second HTTP timeout | Configurable per-node timeout | n8n 1.x (2024+) | Handles high-latency APIs like cloud relays |
|
|
| String-based error checking | Structured error objects with codes | GraphQL spec, Apollo Server pattern | Enables programmatic error handling |
|
|
|
|
**Deprecated/outdated:**
|
|
- **Inline callback data encoding:** Modern bots use server-side mapping (tokens, UUIDs)
|
|
- **Hardcoded API contracts:** Normalization layers standard for multi-API systems
|
|
- **Global timeout settings:** Per-node configuration preferred for mixed latency scenarios
|
|
|
|
**Current best practice (2026):** Separate concerns — ID translation (registry), callback encoding (token mapping), API contracts (normalization), error handling (utility functions). Each layer independent and testable.
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Actual Unraid PrefixedID format in production**
|
|
- What we know: Schema defines `server_hash:container_hash` format, 129 characters typical
|
|
- What's unclear: Exact format varies by Unraid version (6.x vs 7.x), observed in Phase 14 testing
|
|
- Recommendation: Phase 15 uses format documented in Phase 14 verification, builds abstraction layer
|
|
|
|
2. **Token collision rate with 50+ containers**
|
|
- What we know: 8-char hex tokens provide 2^32 space (~4 billion), SHA-256 hash is collision-resistant
|
|
- What's unclear: Real-world collision rate with 50-100 containers in production
|
|
- Recommendation: Implement collision detection with fallback to next 8 chars, monitor in production
|
|
|
|
3. **Unraid API rate limiting thresholds for batch operations**
|
|
- What we know: API implements rate limiting (confirmed in docs), actual thresholds undocumented
|
|
- What's unclear: Requests per minute limit, burst allowance, penalty duration
|
|
- Recommendation: Use batch mutations instead of N individual calls, implement exponential backoff for 429
|
|
|
|
4. **GraphQL response caching behavior**
|
|
- What we know: HTTP 304 Not Modified exists, GraphQL has equivalent error codes
|
|
- What's unclear: Does Unraid API use HTTP caching headers? Is `ALREADY_IN_STATE` a cacheable response?
|
|
- Recommendation: Treat `ALREADY_IN_STATE` same as HTTP 304, don't rely on caching for correctness
|
|
|
|
5. **n8n static data size limits**
|
|
- What we know: Static data persists across executions, stores JSON-serialized objects
|
|
- What's unclear: Maximum size limit for static data storage, performance impact with large registries
|
|
- Recommendation: Monitor registry size in production, refresh on every list/status command (keeps fresh)
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- Project `/home/luc/Projects/unraid-docker-manager/n8n-batch-ui.json` - Bitmap encoding implementation
|
|
- Project `/home/luc/Projects/unraid-docker-manager/n8n-actions.json` - Current Docker API error handling patterns
|
|
- Project `/home/luc/Projects/unraid-docker-manager/CLAUDE.md` - Static data persistence patterns
|
|
- [.planning/phases/14-unraid-api-access/14-RESEARCH.md](/home/luc/Projects/unraid-docker-manager/.planning/phases/14-unraid-api-access/14-RESEARCH.md) - Unraid API contract, container ID format
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Enhanced Telegram callback_data with protobuf + base85](https://seroperson.me/2025/02/05/enhanced-telegram-callback-data/) - Callback encoding strategies
|
|
- [GraphQL Error Handling (Apollo Server)](https://www.apollographql.com/docs/apollo-server/data/errors) - Error array patterns
|
|
- [Common HTTP Errors and How to Debug Them | GraphQL](https://graphql.org/learn/debug-errors/) - GraphQL error structure
|
|
- [n8n HTTP Request timeout configuration](https://docs.n8n.io/hosting/configuration/configuration-examples/execution-timeout/) - Timeout settings
|
|
- [Docker Container ID format](https://dev.to/kalkwst/a-deep-dive-into-container-identification-and-dependency-management-5bh9) - 64-char hex ID background
|
|
|
|
### Tertiary (LOW confidence)
|
|
- [Telegram Bot API](https://core.telegram.org/bots/api) - Callback data limit (64 bytes confirmed by multiple sources)
|
|
- [n8n Data Transformation](https://docs.n8n.io/data/transforming-data/) - General patterns (specific GraphQL details not found)
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH - All components are n8n built-ins or native JavaScript, well-documented
|
|
- Architecture: MEDIUM-HIGH - Patterns verified in existing codebase, new Unraid-specific parts extrapolated from research
|
|
- Pitfalls: MEDIUM - Static data and callback encoding pitfalls from project experience, GraphQL errors from standard patterns, timeout values estimated from Phase 14 research
|
|
|
|
**Research date:** 2026-02-09
|
|
**Valid until:** 30 days (stable domain - n8n patterns and GraphQL standards don't change rapidly)
|
|
|
|
**Critical dependencies for planning:**
|
|
- Container ID format from Phase 14 verification (MUST be documented before Phase 15 planning)
|
|
- Bitmap encoding pattern from `n8n-batch-ui.json` (currently working, extend for Unraid IDs)
|
|
- Static data persistence pattern from `CLAUDE.md` (critical for registry and token storage)
|
|
- Docker API error checking from `n8n-actions.json` (template for GraphQL error handling)
|
|
|
|
**Ready for planning:** YES with caveat - Phase 15 planning depends on Phase 14 verification documenting actual Unraid PrefixedID format observed in test query. If format differs from assumption, registry and token encoding may need adjustment.
|