From 476108980f8229d0fc844499f65efff3c873e744 Mon Sep 17 00:00:00 2001 From: jared Date: Thu, 7 May 2026 11:24:16 -0400 Subject: [PATCH] feat(backup-monitor): add workflow JS files, build script, and generated workflow JSON Co-Authored-By: Claude Sonnet 4.6 --- scripts/build-n8n-workflows.rb | 247 +++++++++++++++++++++++++++++ scripts/n8n-build-email.js | 73 +++++++++ scripts/n8n-evaluate-sources.js | 24 +++ scripts/n8n-workflow-receiver.json | 112 +++++++++++++ scripts/n8n-workflow-reporter.json | 207 ++++++++++++++++++++++++ 5 files changed, 663 insertions(+) create mode 100644 scripts/build-n8n-workflows.rb create mode 100644 scripts/n8n-build-email.js create mode 100644 scripts/n8n-evaluate-sources.js create mode 100644 scripts/n8n-workflow-receiver.json create mode 100644 scripts/n8n-workflow-reporter.json diff --git a/scripts/build-n8n-workflows.rb b/scripts/build-n8n-workflows.rb new file mode 100644 index 0000000..75d66e6 --- /dev/null +++ b/scripts/build-n8n-workflows.rb @@ -0,0 +1,247 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true +# +# Generates importable n8n workflow JSON from component pieces. +# Run: ruby scripts/build-n8n-workflows.rb +# Output: scripts/n8n-workflow-receiver.json +# scripts/n8n-workflow-reporter.json +# +# Before running: +# 1. Complete Task 7 and verify DATATABLE_TYPE / DATATABLE_TYPE_VERSION below. +# 2. After Task 10 (credentials set up), replace REPLACE_WITH_*_CREDENTIAL_ID +# in the generated JSON and re-run to regenerate clean files before import. + +require 'json' +require 'securerandom' + +SCRIPTS_DIR = File.dirname(__FILE__) + +# Verify against the exported scratch-workflow JSON from Task 7. +DATATABLE_TYPE = 'n8n-nodes-base.dataTable' +DATATABLE_TYPE_VERSION = 1 + +evaluate_sources_js = File.read(File.join(SCRIPTS_DIR, 'n8n-evaluate-sources.js')) +build_email_js = File.read(File.join(SCRIPTS_DIR, 'n8n-build-email.js')) + +# ── Workflow 1: Receiver ────────────────────────────────────────────────────── + +receiver = { + name: 'Backup Monitor — Receiver', + nodes: [ + { + parameters: { + httpMethod: 'POST', + path: 'backup-notify', + authentication: 'headerAuth', + responseMode: 'responseNode', + options: {} + }, + id: SecureRandom.uuid, + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [240, 300], + webhookId: SecureRandom.uuid, + credentials: { + httpHeaderAuth: { + id: 'REPLACE_WITH_BACKUP_CREDENTIAL_ID', + name: 'backup-monitor-token' + } + } + }, + { + parameters: { + resource: 'row', + operation: 'insert', + dataTableId: { + '__rl' => true, + mode: 'list', + value: 'REPLACE_WITH_DATATABLE_ID', + cachedResultName: 'backup_runs' + }, + columns: { + mappingMode: 'defineBelow', + value: { + timestamp: '={{ $json.body.timestamp }}', + machine: '={{ $json.body.machine }}', + destination: '={{ $json.body.destination }}', + status: '={{ $json.body.status }}', + bytes_transferred: '={{ $json.body.bytes_transferred }}', + snapshot_id: '={{ $json.body.snapshot_id }}', + log_path: '={{ $json.body.log_path }}', + notes: '={{ $json.body.notes }}' + } + } + }, + id: SecureRandom.uuid, + name: 'Insert to backup_runs', + type: DATATABLE_TYPE, + typeVersion: DATATABLE_TYPE_VERSION, + position: [520, 300] + }, + { + parameters: { + respondWith: 'json', + responseBody: '={{ JSON.stringify({ ok: true }) }}', + options: { responseCode: 200 } + }, + id: SecureRandom.uuid, + name: 'Respond 200', + type: 'n8n-nodes-base.respondToWebhook', + typeVersion: 1.1, + position: [800, 300] + } + ], + connections: { + 'Webhook' => { main: [[{ node: 'Insert to backup_runs', type: 'main', index: 0 }]] }, + 'Insert to backup_runs' => { main: [[{ node: 'Respond 200', type: 'main', index: 0 }]] } + }, + active: false, + settings: { executionOrder: 'v1' }, + pinData: {}, + meta: { templateCredsSetupCompleted: true }, + tags: [] +} + +# ── Workflow 2: Reporter ────────────────────────────────────────────────────── + +reporter = { + name: 'Backup Monitor — Reporter', + nodes: [ + { + parameters: { + rule: { + interval: [ + { + field: 'weeks', + weeksInterval: 1, + triggerAtDay: [5], + triggerAtHour: 8, + triggerAtMinute: 0 + } + ] + } + }, + id: SecureRandom.uuid, + name: 'Every Friday 8 AM', + type: 'n8n-nodes-base.scheduleTrigger', + typeVersion: 1.2, + position: [240, 300] + }, + { + parameters: { + mode: 'manual', + includeOtherFields: false, + assignments: { + assignments: [ + { + id: SecureRandom.uuid, + name: 'sources', + value: '={{ [{"machine":"desktop","destination":"synology"},{"machine":"desktop","destination":"backblaze-b2"},{"machine":"vps-ovh-prod-01","destination":"local"}] }}', + type: 'arrayValue' + } + ] + }, + options: {} + }, + id: SecureRandom.uuid, + name: 'Set sources', + type: 'n8n-nodes-base.set', + typeVersion: 3.4, + position: [460, 300] + }, + { + parameters: { + resource: 'row', + operation: 'getMany', + dataTableId: { + '__rl' => true, + mode: 'list', + value: 'REPLACE_WITH_DATATABLE_ID', + cachedResultName: 'backup_runs' + }, + filters: { + conditions: [ + { + keyName: 'timestamp', + condition: 'gt', + keyValue: '={{ $now.minus({days: 7}).toISO() }}' + } + ] + }, + options: {} + }, + id: SecureRandom.uuid, + name: 'Get backup_runs (last 7d)', + type: DATATABLE_TYPE, + typeVersion: DATATABLE_TYPE_VERSION, + position: [680, 300] + }, + { + parameters: { + mode: 'runOnceForAllItems', + language: 'javaScript', + jsCode: evaluate_sources_js + }, + id: SecureRandom.uuid, + name: 'Evaluate Sources', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [900, 300] + }, + { + parameters: { + mode: 'runOnceForAllItems', + language: 'javaScript', + jsCode: build_email_js + }, + id: SecureRandom.uuid, + name: 'Build Email', + type: 'n8n-nodes-base.code', + typeVersion: 2, + position: [1120, 300] + }, + { + parameters: { + method: 'POST', + url: 'https://app.mailpace.com/api/v1/send', + authentication: 'genericCredentialType', + genericAuthType: 'httpHeaderAuth', + sendBody: true, + specifyBody: 'json', + jsonBody: '={{ { from: "REPLACE_WITH_MAILPACE_FROM_ADDRESS", to: "jaredmswanson@gmail.com", subject: $json.subject, text_body: $json.body } }}', + options: {} + }, + id: SecureRandom.uuid, + name: 'Send via MailPace', + type: 'n8n-nodes-base.httpRequest', + typeVersion: 4.2, + position: [1340, 300], + credentials: { + httpHeaderAuth: { + id: 'REPLACE_WITH_MAILPACE_CREDENTIAL_ID', + name: 'mailpace-token' + } + } + } + ], + connections: { + 'Every Friday 8 AM' => { main: [[{ node: 'Set sources', type: 'main', index: 0 }]] }, + 'Set sources' => { main: [[{ node: 'Get backup_runs (last 7d)', type: 'main', index: 0 }]] }, + 'Get backup_runs (last 7d)' => { main: [[{ node: 'Evaluate Sources', type: 'main', index: 0 }]] }, + 'Evaluate Sources' => { main: [[{ node: 'Build Email', type: 'main', index: 0 }]] }, + 'Build Email' => { main: [[{ node: 'Send via MailPace', type: 'main', index: 0 }]] } + }, + active: false, + settings: { executionOrder: 'v1' }, + pinData: {}, + meta: { templateCredsSetupCompleted: true }, + tags: [] +} + +File.write(File.join(SCRIPTS_DIR, 'n8n-workflow-receiver.json'), JSON.pretty_generate(receiver)) +File.write(File.join(SCRIPTS_DIR, 'n8n-workflow-reporter.json'), JSON.pretty_generate(reporter)) + +puts 'Generated:' +puts " #{SCRIPTS_DIR}/n8n-workflow-receiver.json" +puts " #{SCRIPTS_DIR}/n8n-workflow-reporter.json" diff --git a/scripts/n8n-build-email.js b/scripts/n8n-build-email.js new file mode 100644 index 0000000..569cdcf --- /dev/null +++ b/scripts/n8n-build-email.js @@ -0,0 +1,73 @@ +// Builds subject + body from Evaluate Sources output. +// Uses an array of lines joined at the end to avoid \n escape issues in JSON storage. +const { results, overallStatus } = $input.first().json; +const icon = { green: '✓', yellow: '⚠', red: '✗' }; +const label = { green: 'Backups healthy', yellow: 'Backup warning', red: 'Backup failure' }; +const weekOf = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +const subject = icon[overallStatus] + ' ' + label[overallStatus] + ' — week of ' + weekOf; + +const fmt = ts => ts + ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + : 'unknown'; + +const L = []; + +if (overallStatus === 'green') { + L.push('All backup sources ran successfully this week.'); + L.push(''); + results.forEach(r => + L.push('✓ ' + r.machine + ' / ' + r.destination + + ' (last success: ' + fmt(r.lastSuccess && r.lastSuccess.timestamp) + ')') + ); +} else { + const problems = results.filter(r => r.status !== 'green'); + const healthy = results.filter(r => r.status === 'green'); + + L.push(problems.length + ' source(s) need attention:'); + L.push(''); + + problems.forEach(r => { + L.push(icon[r.status] + ' ' + r.machine + ' / ' + r.destination); + + if (r.status === 'yellow') { + L.push(' Ran this week but never succeeded.'); + r.runs.slice(0, 3).forEach(run => { + var line = ' ' + fmt(run.timestamp) + ' [' + run.status + ']'; + if (run.log_path) line += ' log: ' + run.log_path; + L.push(line); + if (run.notes) L.push(' notes: ' + (run.notes || '').slice(0, 300)); + }); + L.push(''); + L.push(' Next steps:'); + L.push(' 1. Check the log at the path shown above'); + L.push(' 2. For Synology failures: verify LAN reachability (ssh synology) — disks need ~30s to spin up'); + L.push(' 3. Re-run manually: bash ~/.local/bin/backup.sh'); + L.push(' 4. One-off skip (power outage, travel) is expected — yellow is a heads-up, not an alarm'); + L.push(''); + } + + if (r.status === 'red') { + L.push(' No runs reported all week — silent failure.'); + if (r.lastSuccess) { + L.push(' Last known good run: ' + fmt(r.lastSuccess.timestamp)); + if (r.lastSuccess.log_path) L.push(' Last log: ' + r.lastSuccess.log_path); + } else { + L.push(' No history found in the datatable.'); + } + L.push(''); + L.push(' Next steps:'); + L.push(' 1. Check systemd timer: systemctl --user status restic-backup.timer'); + L.push(' 2. Verify backup.sh calls notify_backup.rb at the end'); + L.push(' 3. Check n8n Workflow 1 execution log for webhook errors'); + L.push(' 4. Run manually: bash ~/.local/bin/backup.sh'); + L.push(''); + } + }); + + if (healthy.length > 0) { + L.push('Healthy sources:'); + healthy.forEach(r => L.push('✓ ' + r.machine + ' / ' + r.destination)); + } +} + +return [{ json: { subject, body: L.join('\n') } }]; diff --git a/scripts/n8n-evaluate-sources.js b/scripts/n8n-evaluate-sources.js new file mode 100644 index 0000000..1b162eb --- /dev/null +++ b/scripts/n8n-evaluate-sources.js @@ -0,0 +1,24 @@ +// Runs once for all items. References upstream nodes by exact name. +const sources = $('Set sources').first().json.sources; +const runs = $('Get backup_runs (last 7d)').all().map(item => item.json); +const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + +const results = sources.map(source => { + const sourceRuns = runs.filter(r => + r.machine === source.machine && + r.destination === source.destination && + new Date(r.timestamp) >= weekAgo + ); + const hasSuccess = sourceRuns.some(r => r.status === 'success'); + const hasAnyRun = sourceRuns.length > 0; + const status = hasSuccess ? 'green' : hasAnyRun ? 'yellow' : 'red'; + const lastSuccess = sourceRuns + .filter(r => r.status === 'success') + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0] || null; + return { ...source, status, runs: sourceRuns, lastSuccess }; +}); + +const overallStatus = results.some(r => r.status === 'red') ? 'red' + : results.some(r => r.status === 'yellow') ? 'yellow' : 'green'; + +return [{ json: { results, overallStatus } }]; diff --git a/scripts/n8n-workflow-receiver.json b/scripts/n8n-workflow-receiver.json new file mode 100644 index 0000000..960d3da --- /dev/null +++ b/scripts/n8n-workflow-receiver.json @@ -0,0 +1,112 @@ +{ + "name": "Backup Monitor — Receiver", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "backup-notify", + "authentication": "headerAuth", + "responseMode": "responseNode", + "options": {} + }, + "id": "336a3720-38df-4431-8218-3574c634a23a", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 240, + 300 + ], + "webhookId": "21ee456d-da9f-4c4a-b9ed-cecf1f082ede", + "credentials": { + "httpHeaderAuth": { + "id": "REPLACE_WITH_BACKUP_CREDENTIAL_ID", + "name": "backup-monitor-token" + } + } + }, + { + "parameters": { + "resource": "row", + "operation": "insert", + "dataTableId": { + "__rl": true, + "mode": "list", + "value": "REPLACE_WITH_DATATABLE_ID", + "cachedResultName": "backup_runs" + }, + "columns": { + "mappingMode": "defineBelow", + "value": { + "timestamp": "={{ $json.body.timestamp }}", + "machine": "={{ $json.body.machine }}", + "destination": "={{ $json.body.destination }}", + "status": "={{ $json.body.status }}", + "bytes_transferred": "={{ $json.body.bytes_transferred }}", + "snapshot_id": "={{ $json.body.snapshot_id }}", + "log_path": "={{ $json.body.log_path }}", + "notes": "={{ $json.body.notes }}" + } + } + }, + "id": "4a4d9d99-c1f4-4440-9998-9649afff9fed", + "name": "Insert to backup_runs", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 520, + 300 + ] + }, + { + "parameters": { + "respondWith": "json", + "responseBody": "={{ JSON.stringify({ ok: true }) }}", + "options": { + "responseCode": 200 + } + }, + "id": "8683674c-4010-4c69-bba7-e79a38454863", + "name": "Respond 200", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.1, + "position": [ + 800, + 300 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Insert to backup_runs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Insert to backup_runs": { + "main": [ + [ + { + "node": "Respond 200", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "meta": { + "templateCredsSetupCompleted": true + }, + "tags": [] +} \ No newline at end of file diff --git a/scripts/n8n-workflow-reporter.json b/scripts/n8n-workflow-reporter.json new file mode 100644 index 0000000..1cbaf36 --- /dev/null +++ b/scripts/n8n-workflow-reporter.json @@ -0,0 +1,207 @@ +{ + "name": "Backup Monitor — Reporter", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "weeks", + "weeksInterval": 1, + "triggerAtDay": [ + 5 + ], + "triggerAtHour": 8, + "triggerAtMinute": 0 + } + ] + } + }, + "id": "2595c8cc-eb3f-43f7-93d6-efdb74576d1b", + "name": "Every Friday 8 AM", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1.2, + "position": [ + 240, + 300 + ] + }, + { + "parameters": { + "mode": "manual", + "includeOtherFields": false, + "assignments": { + "assignments": [ + { + "id": "982fc183-fb43-4846-80c5-93027efdcf80", + "name": "sources", + "value": "={{ [{\"machine\":\"desktop\",\"destination\":\"synology\"},{\"machine\":\"desktop\",\"destination\":\"backblaze-b2\"},{\"machine\":\"vps-ovh-prod-01\",\"destination\":\"local\"}] }}", + "type": "arrayValue" + } + ] + }, + "options": {} + }, + "id": "bb24025c-7a59-44d2-8a48-f468955715e1", + "name": "Set sources", + "type": "n8n-nodes-base.set", + "typeVersion": 3.4, + "position": [ + 460, + 300 + ] + }, + { + "parameters": { + "resource": "row", + "operation": "getMany", + "dataTableId": { + "__rl": true, + "mode": "list", + "value": "REPLACE_WITH_DATATABLE_ID", + "cachedResultName": "backup_runs" + }, + "filters": { + "conditions": [ + { + "keyName": "timestamp", + "condition": "gt", + "keyValue": "={{ $now.minus({days: 7}).toISO() }}" + } + ] + }, + "options": {} + }, + "id": "fccd484f-0383-4581-a385-8ba3187d8218", + "name": "Get backup_runs (last 7d)", + "type": "n8n-nodes-base.dataTable", + "typeVersion": 1, + "position": [ + 680, + 300 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "// Runs once for all items. References upstream nodes by exact name.\nconst sources = $('Set sources').first().json.sources;\nconst runs = $('Get backup_runs (last 7d)').all().map(item => item.json);\nconst weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);\n\nconst results = sources.map(source => {\n const sourceRuns = runs.filter(r =>\n r.machine === source.machine &&\n r.destination === source.destination &&\n new Date(r.timestamp) >= weekAgo\n );\n const hasSuccess = sourceRuns.some(r => r.status === 'success');\n const hasAnyRun = sourceRuns.length > 0;\n const status = hasSuccess ? 'green' : hasAnyRun ? 'yellow' : 'red';\n const lastSuccess = sourceRuns\n .filter(r => r.status === 'success')\n .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0] || null;\n return { ...source, status, runs: sourceRuns, lastSuccess };\n});\n\nconst overallStatus = results.some(r => r.status === 'red') ? 'red'\n : results.some(r => r.status === 'yellow') ? 'yellow' : 'green';\n\nreturn [{ json: { results, overallStatus } }];\n" + }, + "id": "6786e632-14ed-4a58-ae22-5b4ffcaf4cc5", + "name": "Evaluate Sources", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 900, + 300 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "// Builds subject + body from Evaluate Sources output.\n// Uses an array of lines joined at the end to avoid \\n escape issues in JSON storage.\nconst { results, overallStatus } = $input.first().json;\nconst icon = { green: '✓', yellow: '⚠', red: '✗' };\nconst label = { green: 'Backups healthy', yellow: 'Backup warning', red: 'Backup failure' };\nconst weekOf = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric' });\nconst subject = icon[overallStatus] + ' ' + label[overallStatus] + ' — week of ' + weekOf;\n\nconst fmt = ts => ts\n ? new Date(ts).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })\n : 'unknown';\n\nconst L = [];\n\nif (overallStatus === 'green') {\n L.push('All backup sources ran successfully this week.');\n L.push('');\n results.forEach(r =>\n L.push('✓ ' + r.machine + ' / ' + r.destination +\n ' (last success: ' + fmt(r.lastSuccess && r.lastSuccess.timestamp) + ')')\n );\n} else {\n const problems = results.filter(r => r.status !== 'green');\n const healthy = results.filter(r => r.status === 'green');\n\n L.push(problems.length + ' source(s) need attention:');\n L.push('');\n\n problems.forEach(r => {\n L.push(icon[r.status] + ' ' + r.machine + ' / ' + r.destination);\n\n if (r.status === 'yellow') {\n L.push(' Ran this week but never succeeded.');\n r.runs.slice(0, 3).forEach(run => {\n var line = ' ' + fmt(run.timestamp) + ' [' + run.status + ']';\n if (run.log_path) line += ' log: ' + run.log_path;\n L.push(line);\n if (run.notes) L.push(' notes: ' + (run.notes || '').slice(0, 300));\n });\n L.push('');\n L.push(' Next steps:');\n L.push(' 1. Check the log at the path shown above');\n L.push(' 2. For Synology failures: verify LAN reachability (ssh synology) — disks need ~30s to spin up');\n L.push(' 3. Re-run manually: bash ~/.local/bin/backup.sh');\n L.push(' 4. One-off skip (power outage, travel) is expected — yellow is a heads-up, not an alarm');\n L.push('');\n }\n\n if (r.status === 'red') {\n L.push(' No runs reported all week — silent failure.');\n if (r.lastSuccess) {\n L.push(' Last known good run: ' + fmt(r.lastSuccess.timestamp));\n if (r.lastSuccess.log_path) L.push(' Last log: ' + r.lastSuccess.log_path);\n } else {\n L.push(' No history found in the datatable.');\n }\n L.push('');\n L.push(' Next steps:');\n L.push(' 1. Check systemd timer: systemctl --user status restic-backup.timer');\n L.push(' 2. Verify backup.sh calls notify_backup.rb at the end');\n L.push(' 3. Check n8n Workflow 1 execution log for webhook errors');\n L.push(' 4. Run manually: bash ~/.local/bin/backup.sh');\n L.push('');\n }\n });\n\n if (healthy.length > 0) {\n L.push('Healthy sources:');\n healthy.forEach(r => L.push('✓ ' + r.machine + ' / ' + r.destination));\n }\n}\n\nreturn [{ json: { subject, body: L.join('\\n') } }];\n" + }, + "id": "09476b39-8adb-490b-b667-8b2e36bb141c", + "name": "Build Email", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1120, + 300 + ] + }, + { + "parameters": { + "method": "POST", + "url": "https://app.mailpace.com/api/v1/send", + "authentication": "genericCredentialType", + "genericAuthType": "httpHeaderAuth", + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { from: \"REPLACE_WITH_MAILPACE_FROM_ADDRESS\", to: \"jaredmswanson@gmail.com\", subject: $json.subject, text_body: $json.body } }}", + "options": {} + }, + "id": "ecb35506-d3cf-4d3b-b055-fd04d695446a", + "name": "Send via MailPace", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1340, + 300 + ], + "credentials": { + "httpHeaderAuth": { + "id": "REPLACE_WITH_MAILPACE_CREDENTIAL_ID", + "name": "mailpace-token" + } + } + } + ], + "connections": { + "Every Friday 8 AM": { + "main": [ + [ + { + "node": "Set sources", + "type": "main", + "index": 0 + } + ] + ] + }, + "Set sources": { + "main": [ + [ + { + "node": "Get backup_runs (last 7d)", + "type": "main", + "index": 0 + } + ] + ] + }, + "Get backup_runs (last 7d)": { + "main": [ + [ + { + "node": "Evaluate Sources", + "type": "main", + "index": 0 + } + ] + ] + }, + "Evaluate Sources": { + "main": [ + [ + { + "node": "Build Email", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Email": { + "main": [ + [ + { + "node": "Send via MailPace", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "active": false, + "settings": { + "executionOrder": "v1" + }, + "pinData": {}, + "meta": { + "templateCredsSetupCompleted": true + }, + "tags": [] +} \ No newline at end of file