feat(backup-monitor): add workflow JS files, build script, and generated workflow JSON
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
69f15d5fd2
commit
476108980f
|
|
@ -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"
|
||||||
|
|
@ -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') } }];
|
||||||
|
|
@ -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 } }];
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
|
|
@ -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": []
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue