{
  "name": "nola \u2014 proactive monitor",
  "nodes": [
    {
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 5
            }
          ]
        }
      },
      "id": "schedule-trigger",
      "name": "Every 5 Minutes",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Query all configured Netdata hosts in parallel\nconst hostString = process.env.NETDATA_HOSTS || '';\nif (!hostString) return [{ json: { alerts: ['NETDATA_HOSTS not configured'], isCritical: false } }];\n\nconst hosts = {};\nhostString.split(',').forEach(h => {\n  const colonIdx = h.indexOf(':http');\n  if (colonIdx > 0) {\n    const name = h.slice(0, colonIdx).trim();\n    const url  = 'http' + h.slice(colonIdx + 1).trim();\n    hosts[name] = url;\n  }\n});\n\nconst THRESHOLDS = {\n  cpu:    85,  // %\n  ram:    90,  // %\n  disk:   85,  // %\n  uptime: 900  // seconds \u2014 flag if uptime < 15min (recently rebooted)\n};\n\nconst alerts = [];\n\nawait Promise.all(Object.entries(hosts).map(async ([name, baseUrl]) => {\n  try {\n    const resp = await $helpers.httpRequest({\n      method: 'GET',\n      url: `${baseUrl}/api/v1/allmetrics?format=json`,\n      timeout: 8000\n    });\n    const d = typeof resp === 'string' ? JSON.parse(resp) : resp;\n\n    // CPU\n    const cpuIdle = d?.['system.cpu']?.dimensions?.idle?.value;\n    if (cpuIdle !== undefined) {\n      const cpuUsed = 100 - cpuIdle;\n      if (cpuUsed > THRESHOLDS.cpu) alerts.push(`\u26a0\ufe0f ${name}: CPU ${cpuUsed.toFixed(1)}% (threshold ${THRESHOLDS.cpu}%)`);\n    }\n\n    // RAM\n    const ramUsed = d?.['system.ram']?.dimensions?.used?.value;\n    const ramFree = d?.['system.ram']?.dimensions?.free?.value;\n    const ramBuff = (d?.['system.ram']?.dimensions?.buffers?.value || 0) + (d?.['system.ram']?.dimensions?.cached?.value || 0);\n    if (ramUsed !== undefined && ramFree !== undefined) {\n      const total = ramUsed + ramFree + ramBuff;\n      const pct   = total > 0 ? (ramUsed / total) * 100 : 0;\n      if (pct > THRESHOLDS.ram) alerts.push(`\u26a0\ufe0f ${name}: RAM ${pct.toFixed(1)}% used (threshold ${THRESHOLDS.ram}%)`);\n    }\n\n    // Uptime\n    const uptime = d?.['system.uptime']?.dimensions?.uptime?.value;\n    if (uptime !== undefined && uptime < THRESHOLDS.uptime) {\n      alerts.push(`\ud83d\udd14 ${name}: recently rebooted (uptime ${Math.round(uptime / 60)} min)`);\n    }\n\n  } catch (err) {\n    alerts.push(`\ud83d\udd34 ${name}: unreachable \u2014 ${err.message}`);\n  }\n}));\n\n// UPS check\ntry {\n  const net = require('net');\n  const UPS_HOST = process.env.UPS_HOST;\n  const UPS_PORT = parseInt(process.env.UPS_PORT || '3551');\n  if (UPS_HOST) {\n    const upsRaw = await new Promise((resolve, reject) => {\n      const client = net.createConnection(UPS_PORT, UPS_HOST, () => {\n        const cmd = Buffer.alloc(8);\n        cmd.writeUInt16BE(6, 0);\n        cmd.write('status', 2);\n        client.write(cmd);\n      });\n      let data = Buffer.alloc(0);\n      client.on('data', c => { data = Buffer.concat([data, c]); });\n      client.on('end', () => resolve(data.toString('ascii')));\n      client.on('error', reject);\n      setTimeout(() => { client.destroy(); reject(new Error('timeout')); }, 5000);\n    });\n    const kv = {};\n    upsRaw.split('\\n').forEach(line => {\n      const i = line.indexOf(':');\n      if (i > 0) kv[line.slice(0, i).trim()] = line.slice(i + 1).trim();\n    });\n    const status  = kv['STATUS']  || '';\n    const bcharge = parseFloat(kv['BCHARGE'] || '100');\n    if (status.includes('ONBATT')) {\n      alerts.push(`\ud83d\udd34 UPS ON BATTERY \u2014 battery ${bcharge}%`);\n    } else if (bcharge < 30) {\n      alerts.push(`\ud83d\udd34 UPS battery critically low: ${bcharge}%`);\n    }\n  }\n} catch (err) {\n  // UPS check failure is not itself an alert \u2014 host may not have UPS\n}\n\nconst isCritical = alerts.some(a =>\n  a.includes('ON BATTERY') ||\n  a.includes('battery critically low') ||\n  a.includes('unreachable')\n);\n\nreturn [{ json: { alerts, isCritical, alertCount: alerts.length } }];"
      },
      "id": "check-hosts",
      "name": "Check All Hosts",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{ $json.alertCount }}",
              "operation": "larger",
              "value2": 0
            }
          ]
        }
      },
      "id": "if-alerts",
      "name": "Any Alerts?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Alert deduplication \u2014 suppress repeated identical alerts within the cooldown window.\n// State persists across executions via $workflow.staticData (n8n built-in per-workflow KV).\n//\n// Cooldown window: ALERT_DEDUP_MINUTES env var (default 30 minutes).\n// Critical alerts bypass dedup \u2014 they always fire.\nconst alerts     = $input.first().json.alerts;\nconst isCritical = $input.first().json.isCritical;\nconst alertCount = $input.first().json.alertCount;\n\nconst COOLDOWN_MS   = parseInt(process.env.ALERT_DEDUP_MINUTES || '30') * 60 * 1000;\nconst fingerprint   = [...alerts].sort().join('|');\nconst state         = $workflow.staticData;\nconst now           = Date.now();\n\nconst sameFingerprint = state.lastFingerprint === fingerprint;\nconst withinCooldown  = state.lastSentAt && (now - state.lastSentAt) < COOLDOWN_MS;\n\nif (!isCritical && sameFingerprint && withinCooldown) {\n  // Identical non-critical alerts already sent within the cooldown window \u2014 suppress\n  return [];\n}\n\n// New, changed, or critical alerts \u2014 update state and pass through\nstate.lastFingerprint = fingerprint;\nstate.lastSentAt      = now;\n\nreturn [{ json: { alerts, isCritical, alertCount } }];"
      },
      "id": "dedup-check",
      "name": "Dedup Check",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Summarize alerts with Ollama (containy.galaxy)\n// Falls back gracefully if Ollama is unavailable.\nconst alerts = $input.first().json.alerts;\nconst isCritical = $input.first().json.isCritical;\nconst alertCount = $input.first().json.alertCount;\n\nconst ollamaUrl = process.env.OLLAMA_URL_CONTAINY || 'http://containy.galaxy:11434';\nconst model = process.env.OLLAMA_MODEL || 'llama3.2:3b';\n\nconst prompt = `You are NOLA, a homelab monitoring assistant. Summarize these infrastructure alerts in 1-2 sentences. Be direct and technical \u2014 provide insight or context, do not just repeat the raw data verbatim.\\n\\nAlerts:\\n${alerts.join('\\n')}`;\n\nlet aiSummary = null;\ntry {\n  const resp = await $helpers.httpRequest({\n    method: 'POST',\n    url: `${ollamaUrl}/api/generate`,\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ model, prompt, stream: false }),\n    timeout: 15000\n  });\n  const data = typeof resp === 'string' ? JSON.parse(resp) : resp;\n  aiSummary = data?.response?.trim() || null;\n} catch (err) {\n  // Ollama unavailable \u2014 continue without AI summary\n}\n\nreturn [{ json: { alerts, isCritical, alertCount, aiSummary } }];"
      },
      "id": "ollama-summarize",
      "name": "Summarize with Ollama",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.DISCORD_WEBHOOK_URL }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"content\": \"\ud83d\udd14 **NOLA Proactive Monitor**\\n{{ $json.alerts.join('\\n') }}{{ $json.aiSummary ? '\\n\\n\ud83d\udcac ' + $json.aiSummary : '' }}\"\n}",
        "options": {}
      },
      "id": "post-discord",
      "name": "Post Alerts to Discord",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        1320,
        200
      ]
    },
    {
      "parameters": {
        "conditions": {
          "boolean": [
            {
              "value1": "={{ $('Summarize with Ollama').first().json.isCritical }}",
              "value2": true
            }
          ]
        }
      },
      "id": "if-critical",
      "name": "Is Critical?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        1320,
        400
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://api.vapi.ai/call/phone",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $env.VAPI_API_KEY }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"assistantId\": \"{{ $env.VAPI_ASSISTANT_ID }}\",\n  \"phoneNumberId\": \"{{ $env.VAPI_PHONE_NUMBER_ID }}\",\n  \"customer\": { \"number\": \"{{ $env.ALERT_PHONE_NUMBER }}\" },\n  \"assistantOverrides\": {\n    \"firstMessage\": \"This is NOLA with a critical alert. {{ $('Check All Hosts').first().json.alerts.join('. ') }}\"\n  }\n}",
        "options": {}
      },
      "id": "vapi-call",
      "name": "VAPI Phone Call",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4,
      "position": [
        1540,
        400
      ]
    }
  ],
  "connections": {
    "Every 5 Minutes": {
      "main": [
        [
          {
            "node": "Check All Hosts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check All Hosts": {
      "main": [
        [
          {
            "node": "Any Alerts?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any Alerts?": {
      "main": [
        [
          {
            "node": "Dedup Check",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Dedup Check": {
      "main": [
        [
          {
            "node": "Summarize with Ollama",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Summarize with Ollama": {
      "main": [
        [
          {
            "node": "Post Alerts to Discord",
            "type": "main",
            "index": 0
          },
          {
            "node": "Is Critical?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Post Alerts to Discord": {
      "main": [
        []
      ]
    },
    "Is Critical?": {
      "main": [
        [
          {
            "node": "VAPI Phone Call",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1"
  },
  "tags": [
    "nola",
    "monitor"
  ]
}