{
  "name": "Duplicati Backup Staleness Monitor",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "hours",
              "hoursInterval": 12
            }
          ]
        }
      },
      "id": "schedule-trigger",
      "name": "Every 12 Hours",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        0
      ]
    },
    {
      "parameters": {
        "authentication": "privateKey",
        "command": "(cat /opt/n8n/data/duplicati_backup_log.jsonl 2>/dev/null || echo '') | grep -v '\"backupName\":\"Test Backup\"'"
      },
      "id": "read-log",
      "name": "Read Backup Log",
      "type": "n8n-nodes-base.ssh",
      "typeVersion": 1,
      "position": [
        224,
        0
      ],
      "credentials": {
        "sshPrivateKey": {
          "id": "YOUR_SSH_CREDENTIAL_ID",
          "name": "Your SSH Key"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "const stdout = $input.first().json.stdout || '';\nconst lines = stdout.trim().split('\\n').filter(l => l.trim());\n\nif (lines.length === 0) {\n  return { json: { staleBackups: [], hasStale: false, message: 'No backup logs found' } };\n}\n\n// Parse all log entries and find latest per backup\nconst latestByBackup = {};\nfor (const line of lines) {\n  try {\n    const entry = JSON.parse(line);\n    const name = entry.backupName;\n    const timestamp = new Date(entry.timestamp);\n    \n    if (!latestByBackup[name] || timestamp > new Date(latestByBackup[name].timestamp)) {\n      latestByBackup[name] = entry;\n    }\n  } catch (e) {\n    // Skip malformed lines\n  }\n}\n\n// Check for stale backups with special handling for weekly jobs\nconst now = new Date();\nconst defaultStaleThreshold = 36; // hours - adjust for your schedule\nconst specialThresholds = {\n  // Add your weekly jobs here with longer thresholds\n  // \"Weekly Cloud Backup\": 192 // 8 days = 168 hours + 24 hour buffer\n};\nconst staleBackups = [];\n\nfor (const [name, entry] of Object.entries(latestByBackup)) {\n  const staleThresholdHours = specialThresholds[name] || defaultStaleThreshold;\n  const lastBackup = new Date(entry.timestamp);\n  const hoursAgo = (now - lastBackup) / (1000 * 60 * 60);\n\n  if (hoursAgo > staleThresholdHours) {\n    staleBackups.push({\n      backupName: name,\n      lastBackup: entry.timestamp,\n      hoursAgo: Math.round(hoursAgo),\n      lastResult: entry.parsedResult\n    });\n  }\n}\n\nconst hasStale = staleBackups.length > 0;\nlet alertMessage = '';\n\nif (hasStale) {\n  alertMessage = `\\u26a0\\ufe0f STALE BACKUPS DETECTED\\n\\n`;\n  for (const b of staleBackups) {\n    alertMessage += `\\u2022 ${b.backupName}: ${b.hoursAgo}h ago (${b.lastResult})\\n`;\n  }\n}\n\nreturn {\n  json: {\n    staleBackups,\n    hasStale,\n    alertMessage,\n    totalBackups: Object.keys(latestByBackup).length,\n    checkedAt: now.toISOString()\n  }\n};"
      },
      "id": "check-staleness",
      "name": "Check for Stale Backups",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        448,
        0
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": false,
            "typeValidation": "loose"
          },
          "conditions": [
            {
              "id": "check-stale",
              "leftValue": "={{ $json.hasStale }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "equals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "has-stale",
      "name": "Has Stale Backups?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        672,
        0
      ]
    },
    {
      "parameters": {
        "channel_id": "YOUR_CHANNEL_ID",
        "notification": "={{ $json.alertMessage }}"
      },
      "id": "pushinator-stale",
      "name": "Pushinator Stale Alert",
      "type": "n8n-nodes-pushinator.pushinator",
      "typeVersion": 1,
      "position": [
        896,
        -64
      ],
      "credentials": {
        "pushinatorApi": {
          "id": "YOUR_PUSHINATOR_CREDENTIAL_ID",
          "name": "Pushinator account"
        }
      }
    },
    {
      "parameters": {
        "sendTo": "your-email@example.com",
        "subject": "=\u26a0\ufe0f Stale Duplicati Backups Detected",
        "message": "=<h2 style=\"color: #f57c00;\">Stale Backups Detected!</h2>\n\n<p>The following backups have not reported in over 36 hours:</p>\n\n<table style=\"border-collapse: collapse; margin: 10px 0; width: 100%;\">\n<tr style=\"background: #f5f5f5;\"><th style=\"padding: 8px; text-align: left;\">Backup Name</th><th style=\"padding: 8px;\">Hours Ago</th><th style=\"padding: 8px;\">Last Result</th></tr>\n{{ $json.staleBackups.map(b => `<tr><td style=\"padding: 8px;\">${b.backupName}</td><td style=\"padding: 8px; text-align: center;\">${b.hoursAgo}h</td><td style=\"padding: 8px; text-align: center;\">${b.lastResult}</td></tr>`).join('') }}\n</table>\n\n<p style=\"color: #666; font-size: 12px;\">Check Duplicati services and ensure backup jobs are running.</p>",
        "options": {}
      },
      "id": "email-stale",
      "name": "Email Stale Alert",
      "type": "n8n-nodes-base.gmail",
      "typeVersion": 2.1,
      "position": [
        896,
        48
      ],
      "credentials": {
        "gmailOAuth2": {
          "id": "YOUR_GMAIL_CREDENTIAL_ID",
          "name": "Gmail account"
        }
      }
    },
    {
      "parameters": {},
      "id": "no-stale",
      "name": "All Backups Fresh",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        896,
        160
      ]
    },
    {
      "parameters": {},
      "id": "manual-trigger",
      "name": "Manual Test",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        192
      ]
    }
  ],
  "pinData": {},
  "connections": {
    "Every 12 Hours": {
      "main": [
        [
          {
            "node": "Read Backup Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read Backup Log": {
      "main": [
        [
          {
            "node": "Check for Stale Backups",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for Stale Backups": {
      "main": [
        [
          {
            "node": "Has Stale Backups?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has Stale Backups?": {
      "main": [
        [
          {
            "node": "Pushinator Stale Alert",
            "type": "main",
            "index": 0
          },
          {
            "node": "Email Stale Alert",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "All Backups Fresh",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Manual Test": {
      "main": [
        [
          {
            "node": "Read Backup Log",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "callerPolicy": "workflowsFromSameOwner"
  },
  "meta": {
    "notes": [
      {
        "id": "note-1",
        "type": "sticky",
        "content": "## Duplicati Backup Staleness Monitor\n\nChecks every 12 hours whether any Duplicati backup job has gone silent.\n\n**Setup:**\n1. Configure Duplicati jobs to POST webhooks (see article)\n2. Update SSH credential to point at your n8n host\n3. Update email and Pushinator credentials\n4. Adjust thresholds in the Code node for your schedule\n\n**Default:** 36h for daily jobs. Add weekly jobs to specialThresholds.\n\nDesigned by ClauDeLay \u2022 thedelay.com",
        "position": [
          -300,
          -200
        ],
        "width": 350,
        "height": 300
      }
    ]
  },
  "tags": []
}
