{
  "id": "fVm21DSKcT3JpgnI",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Monitor Docker host health via SSH with AI analysis and alerts to Discord",
  "tags": [],
  "nodes": [
    {
      "id": "sticky-overview",
      "name": "Sticky Note - Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -560,
        -416
      ],
      "parameters": {
        "width": 620,
        "height": 880,
        "content": "# Homelab Health Dashboard\n\nAutomated daily health monitoring for your homelab. SSH into your Docker host, collect 30+ system and container metrics, analyze with AI for actionable insights, and get a structured multi-embed dashboard delivered to Discord.\n\n### How it works\n1. Schedule Trigger fires daily at 7:00 AM (configurable)\n2. SSH collects system metrics (real CPU %, memory, all filesystems, swap, network I/O, top processes, zombies, failed services) and Docker metrics (container stats, health checks, disk usage, dangling images)\n3. A Code node parses everything into structured data and calculates a 100-point health score\n4. Historical metrics are loaded from Google Sheets for trend comparison\n5. AI analyzes current vs. historical data and returns structured JSON with severity-tagged findings and fix commands\n6. A 4-embed Discord dashboard is built: status header with inline metrics, actionable findings with CLI commands, Docker ecosystem + trends, and a footer with cost/timing\n7. Today's metrics are stored in Google Sheets for future trend analysis\n8. A separate path runs every 5 minutes for critical threshold alerts\n\n### Setup steps\n1. Add your SSH credentials - [SSH key setup guide](https://nxsi.io/guides/ssh-key-setup)\n2. Add your OpenAI API key - [OpenAI API setup guide](https://nxsi.io/guides/openai-api-setup)\n3. Add Google Sheets credentials - [n8n Google Sheets docs](https://docs.n8n.io/integrations/builtin/credentials/google/oauth-single-service/)\n4. Click \"Test workflow\" on the \"\u25b6\ufe0f Run first-time setup\" trigger to create your tracking spreadsheet\n5. Copy the Sheet ID into the \"\u2699\ufe0f Configure monitoring settings\" node\n6. Add your Discord webhook URL - [Discord webhook setup guide](https://nxsi.io/guides/discord-webhook)\n7. Wire credentials to the SSH, Google Sheets, and OpenAI nodes\n8. Run the daily digest path manually to verify, then activate\n\n### Customization\n- Change the digest schedule in the \"Run daily health check\" trigger node\n- Adjust thresholds in the configuration and alert settings nodes\n- Swap OpenAI for Claude or Ollama by replacing the LLM sub-node\n- Replace Discord with Telegram, Slack, or ntfy by changing the webhook URL and payload format"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-config",
      "name": "Sticky Note - Configuration",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 420,
        "height": 440,
        "content": "## \u2699\ufe0f Configuration\nEdit the configuration node below to set your Discord webhook URL, alert thresholds, and monitoring preferences. All user settings are in one place."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-collection",
      "name": "Sticky Note - Data Collection",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 700,
        "height": 440,
        "content": "## \ud83d\udcca Data Collection\nTwo SSH commands collect 30+ metrics in ~2 seconds. System metrics (real CPU %, memory, all filesystems, swap, network I/O, top 5 processes, zombies, failed services, connections) and Docker stats (container status, CPU, memory, restarts, health checks, disk usage, dangling images, unused volumes)."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-analysis",
      "name": "Sticky Note - AI Analysis",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1280,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 764,
        "height": 440,
        "content": "## \ud83e\udd16 AI Analysis\nLoads 7 days of historical metrics from Google Sheets, builds a context-rich prompt with all 30+ data points, and sends to the LLM. The AI returns structured JSON with severity-tagged findings, fix commands, trend analysis, and a top recommendation - no free-form text."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-output",
      "name": "Sticky Note - Delivery",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2080,
        32
      ],
      "parameters": {
        "color": 7,
        "width": 600,
        "height": 520,
        "content": "## \ud83d\udcec Delivery & Storage\nBuilds a 4-embed Discord dashboard: (1) status header with 6 inline metric fields, (2) severity-tagged AI findings with fix commands, (3) Docker ecosystem + network + trends + top recommendation, (4) footer with hostname, timestamp, duration, and API cost. Stores 16 metric columns in Google Sheets for trend analysis."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-critical",
      "name": "Sticky Note - Critical Alerts",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        592
      ],
      "parameters": {
        "color": 7,
        "width": 1580,
        "height": 400,
        "content": "## \ud83d\udea8 Critical Alerts\nRuns every 5 minutes with a lightweight SSH check. Edit the \"\u2699\ufe0f Alert settings\" node to configure your Discord webhook URL and alert thresholds. Fires immediately if disk > 90%, memory > 95%, inodes > 90%, CPU overloaded, or any container is down."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-warning",
      "name": "Sticky Note - API Cost Warning",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1776,
        592
      ],
      "parameters": {
        "color": 3,
        "width": 320,
        "height": 192,
        "content": "\u26a0\ufe0f Requires OpenAI API key\n\n**Quick setup:** Create a key at [platform.openai.com/api-keys](https://platform.openai.com/api-keys), then add it in n8n via Credentials \u2192 OpenAI.\n\n**Full instructions:** [OpenAI API Setup for n8n](https://nxsi.io/guides/openai-api-setup) - covers account creation, model selection, and cost management."
      },
      "typeVersion": 1
    },
    {
      "id": "daily-trigger",
      "name": "Run daily health check",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        208,
        240
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "0 7 * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "config",
      "name": "\u2699\ufe0f Configure monitoring settings",
      "type": "n8n-nodes-base.set",
      "position": [
        400,
        240
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-webhook",
              "name": "discord_webhook_url",
              "type": "string",
              "value": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
            },
            {
              "id": "cfg-disk-warn",
              "name": "disk_warning_pct",
              "type": "number",
              "value": 80
            },
            {
              "id": "cfg-disk-crit",
              "name": "disk_critical_pct",
              "type": "number",
              "value": 90
            },
            {
              "id": "cfg-mem-warn",
              "name": "memory_warning_pct",
              "type": "number",
              "value": 85
            },
            {
              "id": "cfg-mem-crit",
              "name": "memory_critical_pct",
              "type": "number",
              "value": 95
            },
            {
              "id": "cfg-inode-crit",
              "name": "inode_critical_pct",
              "type": "number",
              "value": 90
            },
            {
              "id": "cfg-restart",
              "name": "container_restart_threshold",
              "type": "number",
              "value": 3
            },
            {
              "id": "cfg-history",
              "name": "history_days",
              "type": "number",
              "value": 7
            },
            {
              "id": "cfg-sheet",
              "name": "google_sheet_id",
              "type": "string",
              "value": "YOUR_GOOGLE_SHEET_ID"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "ssh-system",
      "name": "Collect system metrics",
      "type": "n8n-nodes-base.ssh",
      "onError": "continueRegularOutput",
      "position": [
        640,
        240
      ],
      "parameters": {
        "command": "echo \"HOST=$(hostname)\" && echo \"UPTIME=$(uptime -s)\" && echo \"LOAD=$(cat /proc/loadavg)\" && echo \"MEM_TOTAL=$(free -m | awk '/Mem:/{print $2}')\" && echo \"MEM_USED=$(free -m | awk '/Mem:/{print $3}')\" && echo \"MEM_AVAIL=$(free -m | awk '/Mem:/{print $7}')\" && echo \"DISK_TOTAL=$(df -h / | tail -1 | awk '{print $2}')\" && echo \"DISK_USED=$(df -h / | tail -1 | awk '{print $3}')\" && echo \"DISK_PCT=$(df / | tail -1 | awk '{print $5}' | tr -d '%')\" && echo \"INODE_PCT=$(df -i / | tail -1 | awk '{print $5}' | tr -d '%')\" && echo \"SWAP_TOTAL=$(free -m | awk '/Swap:/{print $2}')\" && echo \"SWAP_USED=$(free -m | awk '/Swap:/{print $3}')\" && echo \"TEMP=$(cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null || echo N/A)\" && echo \"CORES=$(nproc)\" && CPU1=$(awk '/^cpu /{print $2+$3+$4+$5+$6+$7+$8\" \"$5}' /proc/stat) && sleep 1 && CPU2=$(awk '/^cpu /{print $2+$3+$4+$5+$6+$7+$8\" \"$5}' /proc/stat) && echo \"CPU_DELTA=$CPU1 $CPU2\" && echo \"---FS---\" && df -P -x tmpfs -x devtmpfs -x squashfs | tail -n +2 && echo \"---NET---\" && awk 'NR>2 && $1!~/(lo|docker|br-|veth)/{gsub(/:/, \"\", $1); print $1\"|\"$2\"|\"$10\"|\"$4\"|\"$12}' /proc/net/dev && echo \"---TOP5CPU---\" && ps aux --sort=-%cpu | awk 'NR>1 && NR<=6{print $11\"|\"$3\"|\"$4\"|\"$1}' && echo \"ZOMBIES=$(ps aux | awk '$8~/Z/{count++} END{print count+0}')\" && echo \"FAILED_SERVICES=$(systemctl --failed --no-legend 2>/dev/null | wc -l)\" && echo \"CONNECTIONS=$(ss -tun state established 2>/dev/null | tail -n +2 | wc -l)\"",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "ssh-docker",
      "name": "Collect Docker container metrics",
      "type": "n8n-nodes-base.ssh",
      "onError": "continueRegularOutput",
      "position": [
        880,
        240
      ],
      "parameters": {
        "command": "echo \"RUNNING=$(docker ps -q | wc -l)\" && echo \"TOTAL=$(docker ps -aq | wc -l)\" && docker ps -a --format '{{.Names}}|{{.Status}}|{{.Image}}' && echo \"---STATS---\" && docker stats --no-stream --format '{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}|{{.PIDs}}' && echo \"---HEALTH---\" && docker inspect --format '{{.Name}}|{{.RestartCount}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $(docker ps -q) 2>/dev/null && echo \"---DISKUSAGE---\" && docker system df --format '{{.Type}}|{{.TotalCount}}|{{.Size}}|{{.Reclaimable}}' && echo \"DANGLING=$(docker images -f dangling=true -q | wc -l)\" && echo \"UNUSED_VOLS=$(docker volume ls -f dangling=true -q | wc -l)\"",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "parse-metrics",
      "name": "Parse and normalize all metrics",
      "type": "n8n-nodes-base.code",
      "position": [
        1120,
        240
      ],
      "parameters": {
        "jsCode": "const sysOut = $('Collect system metrics').first().json.stdout || '';\nconst dkrOut = $('Collect Docker container metrics').first().json.stdout || '';\n\nconst m = {};\nfor (const line of sysOut.split('\\n')) {\n  const idx = line.indexOf('=');\n  if (idx > 0 && !line.startsWith('---') && !line.includes('|')) {\n    m[line.substring(0, idx).trim()] = line.substring(idx + 1).trim();\n  }\n}\n\n// CPU % from /proc/stat delta\nlet cpuPercent = 0;\nif (m.CPU_DELTA) {\n  const parts = m.CPU_DELTA.split(' ');\n  if (parts.length >= 4) {\n    const total1 = parseFloat(parts[0]) || 0;\n    const idle1 = parseFloat(parts[1]) || 0;\n    const total2 = parseFloat(parts[2]) || 0;\n    const idle2 = parseFloat(parts[3]) || 0;\n    const totalDiff = total2 - total1;\n    const idleDiff = idle2 - idle1;\n    cpuPercent = totalDiff > 0 ? Math.round(((totalDiff - idleDiff) / totalDiff) * 1000) / 10 : 0;\n  }\n}\n\nconst loadParts = (m.LOAD || '0 0 0').split(' ');\nconst memTotal = parseInt(m.MEM_TOTAL) || 0;\nconst memUsed = parseInt(m.MEM_USED) || 0;\nconst memPct = memTotal > 0 ? Math.round((memUsed / memTotal) * 100) : 0;\nconst diskPct = parseInt(m.DISK_PCT) || 0;\nconst inodePct = parseInt(m.INODE_PCT) || 0;\nconst swapTotal = parseInt(m.SWAP_TOTAL) || 0;\nconst swapUsed = parseInt(m.SWAP_USED) || 0;\nconst swapPct = swapTotal > 0 ? Math.round((swapUsed / swapTotal) * 100) : 0;\nconst cores = parseInt(m.CORES) || 1;\nconst zombieCount = parseInt(m.ZOMBIES) || 0;\nconst failedServices = parseInt(m.FAILED_SERVICES) || 0;\nconst connections = parseInt(m.CONNECTIONS) || 0;\nlet cpuTemp = 'N/A';\nif (m.TEMP && m.TEMP !== 'N/A') cpuTemp = (parseInt(m.TEMP) / 1000).toFixed(1);\n\nlet uptimeDays = 0;\nif (m.UPTIME) {\n  const sd = new Date(m.UPTIME);\n  if (!isNaN(sd)) uptimeDays = Math.floor((Date.now() - sd.getTime()) / 86400000);\n}\n\n// Parse filesystems\nconst filesystems = [];\nconst fsSectionMatch = sysOut.split('---FS---');\nif (fsSectionMatch.length > 1) {\n  const fsBlock = fsSectionMatch[1].split('---NET---')[0] || fsSectionMatch[1].split('---TOP5CPU---')[0] || fsSectionMatch[1];\n  for (const line of fsBlock.split('\\n')) {\n    const cols = line.trim().split(/\\s+/);\n    if (cols.length >= 5 && cols[4].match(/\\d+/)) {\n      filesystems.push({\n        device: cols[0],\n        size: cols[1],\n        used: cols[2],\n        mountpoint: cols[5] || '/',\n        percent: parseInt(cols[4].replace('%', '')) || 0\n      });\n    }\n  }\n}\n\n// Parse network I/O\nlet netRxBytes = 0, netTxBytes = 0, netRxErrors = 0, netTxErrors = 0;\nconst netSection = sysOut.split('---NET---');\nif (netSection.length > 1) {\n  const netBlock = netSection[1].split('---TOP5CPU---')[0] || netSection[1];\n  for (const line of netBlock.split('\\n')) {\n    if (!line.includes('|')) continue;\n    const parts = line.split('|');\n    if (parts.length >= 5) {\n      netRxBytes += parseInt(parts[1]) || 0;\n      netTxBytes += parseInt(parts[2]) || 0;\n      netRxErrors += parseInt(parts[3]) || 0;\n      netTxErrors += parseInt(parts[4]) || 0;\n    }\n  }\n}\n\n// Parse top 5 processes by CPU\nconst topProcessesCpu = [];\nconst topSection = sysOut.split('---TOP5CPU---');\nif (topSection.length > 1) {\n  const topBlock = topSection[1].split('ZOMBIES=')[0] || topSection[1];\n  for (const line of topBlock.split('\\n')) {\n    if (!line.includes('|')) continue;\n    const parts = line.split('|');\n    if (parts.length >= 3) {\n      topProcessesCpu.push({\n        command: (parts[0] || '').trim(),\n        cpu: parseFloat(parts[1]) || 0,\n        mem: parseFloat(parts[2]) || 0,\n        user: (parts[3] || '').trim()\n      });\n    }\n  }\n}\n\n// Docker parsing\nconst sections = dkrOut.split('---STATS---');\nconst containerLines = (sections[0] || '').split('\\n');\nconst statsAndHealth = (sections[1] || '').split('---HEALTH---');\nconst statsLines = (statsAndHealth[0] || '').split('\\n');\nconst healthAndDisk = (statsAndHealth[1] || '').split('---DISKUSAGE---');\nconst healthLines = (healthAndDisk[0] || '').split('\\n');\nconst diskUsageLines = (healthAndDisk[1] || '').split('\\n');\n\nlet running = 0, total = 0;\nconst containers = [];\nconst statsMap = {};\nconst healthMap = {};\n\nfor (const line of containerLines) {\n  if (line.startsWith('RUNNING=')) running = parseInt(line.split('=')[1]) || 0;\n  else if (line.startsWith('TOTAL=')) total = parseInt(line.split('=')[1]) || 0;\n  else if (line.includes('|')) {\n    const [name, status, image] = line.split('|');\n    if (name) containers.push({ name: name.trim(), status: (status || '').trim(), image: (image || '').trim() });\n  }\n}\n\nfor (const line of statsLines) {\n  if (!line.includes('|')) continue;\n  const [name, cpu, mem, memP, pids] = line.split('|');\n  if (name) statsMap[name.trim()] = { cpu: (cpu || '').trim(), mem: (mem || '').trim(), memPct: (memP || '').trim(), pids: (pids || '').trim() };\n}\n\nfor (const line of healthLines) {\n  if (!line.includes('|')) continue;\n  const parts = line.split('|');\n  let name = (parts[0] || '').trim();\n  if (name.startsWith('/')) name = name.substring(1);\n  if (name) healthMap[name] = { restarts: parseInt(parts[1]) || 0, health: (parts[2] || 'none').trim() };\n}\n\n// Docker disk usage\nconst dockerDisk = { images_size: 'N/A', images_reclaimable: 'N/A', containers_size: 'N/A', volumes_size: 'N/A', build_cache_size: 'N/A', dangling_images: 0, unused_volumes: 0 };\nfor (const line of diskUsageLines) {\n  if (line.startsWith('DANGLING=')) { dockerDisk.dangling_images = parseInt(line.split('=')[1]) || 0; continue; }\n  if (line.startsWith('UNUSED_VOLS=')) { dockerDisk.unused_volumes = parseInt(line.split('=')[1]) || 0; continue; }\n  if (!line.includes('|')) continue;\n  const parts = line.split('|');\n  const type = (parts[0] || '').trim();\n  if (type === 'Images') { dockerDisk.images_size = (parts[2] || '').trim(); dockerDisk.images_reclaimable = (parts[3] || '').trim(); }\n  else if (type === 'Containers') dockerDisk.containers_size = (parts[2] || '').trim();\n  else if (type === 'Local Volumes') dockerDisk.volumes_size = (parts[2] || '').trim();\n  else if (type === 'Build Cache') dockerDisk.build_cache_size = (parts[2] || '').trim();\n}\n\nconst containerSummary = containers.map(c => {\n  const s = statsMap[c.name] || {};\n  const h = healthMap[c.name] || {};\n  return { name: c.name, status: c.status, image: c.image, cpu: s.cpu || '0%', memory: s.mem || 'N/A', memoryPct: s.memPct || '0%', restarts: h.restarts || 0, healthCheck: h.health || 'none' };\n});\n\nconst downCount = containers.filter(c => !c.status.startsWith('Up')).length;\nconst totalRestarts = containerSummary.reduce((sum, c) => sum + c.restarts, 0);\n\n// Health score with expanded factors\nlet score = 100;\nif (diskPct > 90) score -= 30; else if (diskPct > 80) score -= 15;\nif (memPct > 95) score -= 25; else if (memPct > 85) score -= 10;\nif (cpuPercent > 80) score -= 20; else if (cpuPercent > 50) score -= 10;\nif (parseFloat(loadParts[0]) > cores * 1.5) score -= 10; else if (parseFloat(loadParts[0]) > cores) score -= 5;\nif (inodePct > 90) score -= 20; else if (inodePct > 80) score -= 10;\nif (swapPct > 80) score -= 10; else if (swapPct > 50) score -= 5;\nif (totalRestarts > 5) score -= 15; else if (totalRestarts > 0) score -= 5;\nif (downCount > 0) score -= (downCount * 10);\nif (zombieCount > 0) score -= 5;\nif (failedServices > 0) score -= 10;\nconst reclaimStr = dockerDisk.images_reclaimable || '';\nif (reclaimStr.includes('%)')) {\n  const reclaimPct = parseInt(reclaimStr.match(/(\\d+)%/)?.[1] || '0');\n  if (reclaimPct > 50) score -= 5;\n}\nscore = Math.max(0, score);\n\nreturn [{ json: {\n  timestamp: new Date().toISOString(),\n  hostname: m.HOST || 'unknown',\n  uptime_days: uptimeDays,\n  cpu_percent: cpuPercent,\n  cpu_load_1m: parseFloat(loadParts[0]) || 0,\n  cpu_load_5m: parseFloat(loadParts[1]) || 0,\n  cpu_load_15m: parseFloat(loadParts[2]) || 0,\n  cores: cores,\n  memory_total_mb: memTotal,\n  memory_used_mb: memUsed,\n  memory_percent: memPct,\n  disk_total: m.DISK_TOTAL || 'N/A',\n  disk_used: m.DISK_USED || 'N/A',\n  disk_percent: diskPct,\n  inode_percent: inodePct,\n  swap_total_mb: swapTotal,\n  swap_used_mb: swapUsed,\n  swap_percent: swapPct,\n  cpu_temp_c: cpuTemp,\n  filesystems: filesystems,\n  net_rx_bytes: netRxBytes,\n  net_tx_bytes: netTxBytes,\n  net_rx_errors: netRxErrors,\n  net_tx_errors: netTxErrors,\n  top_processes_cpu: topProcessesCpu,\n  zombie_count: zombieCount,\n  failed_services: failedServices,\n  connections_established: connections,\n  containers_running: running,\n  containers_total: total,\n  containers_down: downCount,\n  total_restarts: totalRestarts,\n  health_score: score,\n  containers: containerSummary,\n  docker_disk: dockerDisk\n}}];"
      },
      "typeVersion": 2
    },
    {
      "id": "read-history",
      "name": "Read metrics history (last 7 days)",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        1360,
        240
      ],
      "parameters": {
        "options": {},
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "metrics"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('\u2699\ufe0f Configure monitoring settings').first().json.google_sheet_id }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5,
      "alwaysOutputData": true
    },
    {
      "id": "build-prompt",
      "name": "Build analysis prompt with history",
      "type": "n8n-nodes-base.code",
      "position": [
        1600,
        240
      ],
      "parameters": {
        "jsCode": "const metrics = $('Parse and normalize all metrics').first().json;\nconst config = $('\u2699\ufe0f Configure monitoring settings').first().json;\n\nconst historyItems = $('Read metrics history (last 7 days)').all();\nlet historyText = 'No historical data available yet (first run).';\nif (historyItems.length > 0 && historyItems[0].json.date) {\n  const recent = historyItems.slice(-7);\n  historyText = 'HISTORICAL DATA (last ' + recent.length + ' days):\\n';\n  for (const item of recent) {\n    const h = item.json;\n    historyText += h.date + ': CPU=' + (h.cpu_percent || h.cpu_load || 'N/A') + '% Mem=' + (h.memory_percent || 'N/A') + '% Disk=' + (h.disk_percent || 'N/A') + '% Swap=' + (h.swap_percent || 'N/A') + '% Score=' + (h.health_score || 'N/A') + ' Containers=' + (h.containers_running || '?') + '/' + (h.containers_total || '?') + ' Net_RX=' + (h.net_rx_bytes || 'N/A') + ' Net_TX=' + (h.net_tx_bytes || 'N/A') + '\\n';\n  }\n}\n\nlet containerTable = '';\nfor (const c of (metrics.containers || [])) {\n  const icon = c.status.startsWith('Up') ? 'UP' : 'DOWN';\n  const restart = c.restarts > 0 ? ' (' + c.restarts + ' restarts)' : '';\n  containerTable += icon + ' ' + c.name + ': ' + c.cpu + ' CPU, ' + c.memory + restart + '\\n';\n}\n\nlet fsTable = '';\nfor (const fs of (metrics.filesystems || [])) {\n  fsTable += fs.mountpoint + ': ' + fs.used + '/' + fs.size + ' (' + fs.percent + '%) [' + fs.device + ']\\n';\n}\n\nlet topProcs = '';\nfor (const p of (metrics.top_processes_cpu || [])) {\n  topProcs += p.command + ': ' + p.cpu + '% CPU, ' + p.mem + '% MEM (user: ' + p.user + ')\\n';\n}\n\nconst dk = metrics.docker_disk || {};\n\nconst prompt = 'Analyze this homelab and respond with ONLY valid JSON (no markdown, no code fences).\\n\\n' +\n  'SYSTEM METRICS:\\n' +\n  '- Host: ' + metrics.hostname + ' | Uptime: ' + metrics.uptime_days + ' days | Cores: ' + metrics.cores + '\\n' +\n  '- CPU: ' + metrics.cpu_percent + '% utilization | Load: ' + metrics.cpu_load_1m + '/' + metrics.cpu_load_5m + '/' + metrics.cpu_load_15m + '\\n' +\n  '- Memory: ' + metrics.memory_used_mb + 'MB/' + metrics.memory_total_mb + 'MB (' + metrics.memory_percent + '%)\\n' +\n  '- Swap: ' + metrics.swap_used_mb + 'MB/' + metrics.swap_total_mb + 'MB (' + metrics.swap_percent + '%)\\n' +\n  '- Temp: ' + metrics.cpu_temp_c + 'C | Connections: ' + metrics.connections_established + ' | Zombies: ' + metrics.zombie_count + ' | Failed services: ' + metrics.failed_services + '\\n\\n' +\n  'FILESYSTEMS:\\n' + (fsTable || 'Root only: ' + metrics.disk_used + '/' + metrics.disk_total + ' (' + metrics.disk_percent + '%)\\n') + '\\n' +\n  'NETWORK I/O (total across physical interfaces):\\n' +\n  '- RX: ' + (metrics.net_rx_bytes / 1073741824).toFixed(2) + ' GB | TX: ' + (metrics.net_tx_bytes / 1073741824).toFixed(2) + ' GB\\n' +\n  '- Errors: RX=' + metrics.net_rx_errors + ' TX=' + metrics.net_tx_errors + '\\n\\n' +\n  'TOP 5 PROCESSES BY CPU:\\n' + (topProcs || 'N/A\\n') + '\\n' +\n  'DOCKER ECOSYSTEM:\\n' +\n  '- Containers: ' + metrics.containers_running + ' running / ' + metrics.containers_total + ' total (' + metrics.containers_down + ' down)\\n' +\n  '- Images: ' + dk.images_size + ' (reclaimable: ' + dk.images_reclaimable + ')\\n' +\n  '- Volumes: ' + dk.volumes_size + ' | Build cache: ' + dk.build_cache_size + '\\n' +\n  '- Dangling images: ' + dk.dangling_images + ' | Unused volumes: ' + dk.unused_volumes + '\\n\\n' +\n  'CONTAINERS:\\n' + containerTable + '\\n' +\n  historyText + '\\n\\n' +\n  'Health Score: ' + metrics.health_score + '/100\\n' +\n  'Thresholds: Disk warn=' + (config.disk_warning_pct || 80) + '% crit=' + (config.disk_critical_pct || 90) + '% | Mem warn=' + (config.memory_warning_pct || 85) + '% crit=' + (config.memory_critical_pct || 95) + '%\\n\\n' +\n  'Respond with this exact JSON structure:\\n' +\n  '{\"status\":\"healthy|warning|critical\",\"headline\":\"One-sentence summary\",\"findings\":[{\"severity\":\"high|medium|low\",\"title\":\"Short title\",\"detail\":\"Explanation\",\"command\":\"fix command or null\"}],\"trend_summary\":\"Historical comparison or null if no data\",\"top_recommendation\":\"Single most impactful action\"}';\n\nreturn [{ json: { prompt: prompt } }];"
      },
      "typeVersion": 2
    },
    {
      "id": "llm-chain",
      "name": "Generate daily health digest",
      "type": "@n8n/n8n-nodes-langchain.chainLlm",
      "position": [
        1792,
        240
      ],
      "parameters": {
        "text": "={{ $json.prompt }}",
        "batching": {},
        "messages": {
          "messageValues": [
            {
              "message": "You are a senior DevOps engineer reviewing a homelab. Every finding must be ACTIONABLE -- include the exact command to fix it or investigate it. Do not restate numbers the admin can already see in the dashboard. Focus on: what changed since yesterday, what is about to break, and what to do about it.\n\nRules:\n- Only report findings that require action or awareness. \"Memory is at 77%\" is NOT a finding. \"Memory increased 12% in 3 days, projected to hit 95% by Friday\" IS a finding.\n- Every finding with severity high or medium MUST include a command field with a real CLI command.\n- If no issues exist, return an empty findings array -- do not invent problems.\n- The headline should be conversational and specific, not generic.\n- Respond with ONLY valid JSON. No markdown, no code fences, no explanation text."
            }
          ]
        },
        "promptType": "define"
      },
      "typeVersion": 1.7
    },
    {
      "id": "openai-model",
      "name": "OpenAI GPT-4o-mini",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "position": [
        1840,
        464
      ],
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "gpt-4o-mini"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "format-digest",
      "name": "Format digest for Discord",
      "type": "n8n-nodes-base.code",
      "position": [
        2112,
        240
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const rawText = $json.text || $json.output || '{}';\nconst metrics = $('Parse and normalize all metrics').first().json;\nconst score = metrics.health_score || 0;\nconst startTime = new Date($('Parse and normalize all metrics').first().json.timestamp);\n\n// Parse AI JSON response\nlet ai = {};\ntry {\n  let cleaned = rawText.trim();\n  if (cleaned.startsWith('```')) {\n    cleaned = cleaned.replace(/^```(?:json)?\\n?/, '').replace(/\\n?```$/, '');\n  }\n  ai = JSON.parse(cleaned);\n} catch (e) {\n  ai = { status: 'warning', headline: 'AI response was not valid JSON', findings: [{ severity: 'medium', title: 'AI Parse Error', detail: rawText.substring(0, 300), command: null }], trend_summary: null, top_recommendation: 'Check AI model output format' };\n}\n\n// Token/cost tracking\nlet inputTokens = 0, outputTokens = 0;\nif ($json.tokenUsageEstimate) {\n  inputTokens = $json.tokenUsageEstimate.promptTokens || 0;\n  outputTokens = $json.tokenUsageEstimate.completionTokens || 0;\n}\nif (inputTokens === 0) {\n  const promptText = $('Build analysis prompt with history').first().json.prompt || '';\n  inputTokens = Math.ceil((promptText.length + 800) / 4);\n  outputTokens = Math.ceil(rawText.length / 4);\n}\nconst costUsd = (inputTokens * 0.15 + outputTokens * 0.60) / 1000000;\nconst costDisplay = '$' + costUsd.toFixed(4);\n\n// Colors\nconst green = 0x00cc66;\nconst yellow = 0xffaa00;\nconst red = 0xff3333;\nconst color = score >= 80 ? green : score >= 50 ? yellow : red;\nconst statusEmoji = score >= 80 ? '\\u2705' : score >= 50 ? '\\u26a0\\ufe0f' : '\\ud83d\\udea8';\n\n// Format bytes\nconst fmtBytes = (b) => {\n  if (b >= 1073741824) return (b / 1073741824).toFixed(1) + ' GB';\n  if (b >= 1048576) return (b / 1048576).toFixed(1) + ' MB';\n  if (b >= 1024) return (b / 1024).toFixed(1) + ' KB';\n  return b + ' B';\n};\n\n// Embed 1: Status header with inline fields\nconst fields = [\n  { name: '\\ud83d\\udcca Health', value: score + '/100', inline: true },\n  { name: '\\ud83d\\udda5\\ufe0f CPU', value: metrics.cpu_percent + '%', inline: true },\n  { name: '\\ud83e\\udde0 Memory', value: metrics.memory_percent + '%', inline: true },\n  { name: '\\ud83d\\udcbe Disk /', value: metrics.disk_percent + '%', inline: true },\n  { name: '\\ud83d\\udd04 Swap', value: metrics.swap_percent + '%', inline: true },\n  { name: '\\ud83d\\udc33 Containers', value: metrics.containers_running + '/' + metrics.containers_total, inline: true }\n];\n\n// Add any filesystem over 75%\nfor (const fs of (metrics.filesystems || [])) {\n  if (fs.percent >= 75 && fs.mountpoint !== '/') {\n    fields.push({ name: '\\ud83d\\udcbe ' + fs.mountpoint, value: fs.percent + '%', inline: true });\n  }\n}\n\nconst embed1 = {\n  title: ('\\ud83c\\udfe0 Homelab Health - ' + statusEmoji + ' ' + (ai.headline || 'Analysis complete')).substring(0, 256),\n  color: color,\n  fields: fields.slice(0, 9)\n};\n\n// Embed 2: Findings\nlet findingsText = '';\nconst findings = ai.findings || [];\nif (findings.length === 0) {\n  findingsText = '\\u2705 All clear - no issues detected.';\n} else {\n  for (const f of findings.slice(0, 6)) {\n    const icon = f.severity === 'high' ? '\\ud83d\\udd34' : f.severity === 'medium' ? '\\ud83d\\udfe1' : '\\ud83d\\udfe2';\n    const tag = f.severity.toUpperCase();\n    findingsText += icon + ' **' + tag + ': ' + (f.title || 'Issue') + '**\\n';\n    if (f.detail) findingsText += f.detail.substring(0, 200) + '\\n';\n    if (f.command) findingsText += '```\\n' + f.command.substring(0, 120) + '\\n```\\n';\n  }\n}\n\nconst embed2 = {\n  title: '\\ud83d\\udd0d Findings',\n  description: findingsText.substring(0, 2000),\n  color: color\n};\n\n// Embed 3: Docker ecosystem + trends\nconst dk = metrics.docker_disk || {};\nlet embed3Desc = '**Docker Disk**\\n';\nembed3Desc += '\\ud83d\\uddbc Images: ' + (dk.images_size || 'N/A') + ' (reclaimable: ' + (dk.images_reclaimable || 'N/A') + ')\\n';\nembed3Desc += '\\ud83d\\udce6 Volumes: ' + (dk.volumes_size || 'N/A') + ' | Cache: ' + (dk.build_cache_size || 'N/A') + '\\n';\nif (dk.dangling_images > 0 || dk.unused_volumes > 0) {\n  embed3Desc += '\\u26a0\\ufe0f Dangling images: ' + dk.dangling_images + ' | Unused volumes: ' + dk.unused_volumes + '\\n';\n}\nembed3Desc += '\\n**Network I/O**\\n';\nembed3Desc += '\\ud83d\\udce5 RX: ' + fmtBytes(metrics.net_rx_bytes) + ' | \\ud83d\\udce4 TX: ' + fmtBytes(metrics.net_tx_bytes) + '\\n';\nif (metrics.net_rx_errors > 0 || metrics.net_tx_errors > 0) {\n  embed3Desc += '\\u26a0\\ufe0f Errors: RX=' + metrics.net_rx_errors + ' TX=' + metrics.net_tx_errors + '\\n';\n}\nif (ai.trend_summary) {\n  embed3Desc += '\\n**Trends**\\n' + ai.trend_summary.substring(0, 300) + '\\n';\n}\nif (ai.top_recommendation) {\n  embed3Desc += '\\n\\ud83d\\udca1 **Top Recommendation:** ' + ai.top_recommendation.substring(0, 200);\n}\n\nconst embed3 = {\n  title: '\\ud83d\\udc33 Docker & Trends',\n  description: embed3Desc.substring(0, 1500),\n  color: color\n};\n\n// Embed 4: Footer\nconst now = new Date();\nconst cstTime = now.toLocaleString('en-US', { timeZone: 'America/Chicago', hour: 'numeric', minute: '2-digit', hour12: true });\nconst durationMs = now.getTime() - startTime.getTime();\nconst durationSec = (durationMs / 1000).toFixed(1);\n\nconst embed4 = {\n  description: metrics.hostname + ' | ' + cstTime + ' CST | ' + durationSec + 's | API: ' + costDisplay,\n  color: color\n};\n\nconst payload = {\n  embeds: [embed1, embed2, embed3, embed4]\n};\n\nreturn { json: payload };"
      },
      "typeVersion": 2
    },
    {
      "id": "send-digest",
      "name": "Send daily digest to Discord",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        2320,
        192
      ],
      "parameters": {
        "url": "={{ $('\u2699\ufe0f Configure monitoring settings').first().json.discord_webhook_url }}",
        "method": "POST",
        "options": {
          "timeout": 10000
        },
        "jsonBody": "={{ JSON.stringify($json) }}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 2000
    },
    {
      "id": "store-metrics",
      "name": "Store today's metrics in history",
      "type": "n8n-nodes-base.googleSheets",
      "position": [
        2528,
        368
      ],
      "parameters": {
        "columns": {
          "value": {},
          "schema": [],
          "mappingMode": "autoMapInputData",
          "matchingColumns": [],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {},
        "operation": "append",
        "sheetName": {
          "__rl": true,
          "mode": "name",
          "value": "metrics"
        },
        "documentId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $('\u2699\ufe0f Configure monitoring settings').first().json.google_sheet_id }}"
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "critical-trigger",
      "name": "Check for critical issues",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        208,
        784
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "cronExpression",
              "expression": "*/5 * * * *"
            }
          ]
        }
      },
      "typeVersion": 1.2
    },
    {
      "id": "ssh-quick",
      "name": "Quick system health check",
      "type": "n8n-nodes-base.ssh",
      "onError": "continueRegularOutput",
      "position": [
        576,
        784
      ],
      "parameters": {
        "command": "echo \"DISK_PCT=$(df / | tail -1 | awk '{print $5}' | tr -d '%')\" && echo \"MEM_PCT=$(free | awk '/Mem:/{printf \"%.0f\", $3/$2*100}')\" && echo \"LOAD=$(cat /proc/loadavg | awk '{print $1}')\" && echo \"CORES=$(nproc)\" && echo \"INODE_PCT=$(df -i / | tail -1 | awk '{print $5}' | tr -d '%')\" && docker ps -a --filter status=exited --filter status=restarting --format 'DOWN={{.Names}}' && docker inspect --format 'RESTART={{.Name}}|{{.RestartCount}}' $(docker ps -q) 2>/dev/null",
        "authentication": "privateKey"
      },
      "credentials": {
        "sshPrivateKey": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1
    },
    {
      "id": "check-thresholds",
      "name": "Check against critical thresholds",
      "type": "n8n-nodes-base.code",
      "position": [
        816,
        784
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const config = $('\u2699\ufe0f Alert settings').first().json;\nconst out = $json.stdout || '';\nconst lines = out.split('\\n');\nconst m = {};\nconst downContainers = [];\nconst highRestarts = [];\n\nfor (const line of lines) {\n  if (line.startsWith('DOWN=')) {\n    downContainers.push(line.replace('DOWN=', '').trim());\n  } else if (line.startsWith('RESTART=')) {\n    const val = line.replace('RESTART=', '');\n    const cleaned = val.startsWith('/') ? val.substring(1) : val;\n    const parts = cleaned.split('|');\n    const count = parseInt(parts[1]) || 0;\n    if (count >= 3) highRestarts.push({ name: parts[0], count: count });\n  } else {\n    const idx = line.indexOf('=');\n    if (idx > 0) m[line.substring(0, idx).trim()] = line.substring(idx + 1).trim();\n  }\n}\n\nconst diskPct = parseInt(m.DISK_PCT) || 0;\nconst memPct = parseInt(m.MEM_PCT) || 0;\nconst load = parseFloat(m.LOAD) || 0;\nconst cores = parseInt(m.CORES) || 1;\nconst inodePct = parseInt(m.INODE_PCT) || 0;\n\nconst DISK_CRIT = config.disk_critical_pct || 90;\nconst MEM_CRIT = config.memory_critical_pct || 95;\nconst INODE_CRIT = config.inode_critical_pct || 90;\nconst LOAD_MULT = config.load_multiplier || 1.5;\n\nconst alerts = [];\nif (diskPct >= DISK_CRIT) alerts.push('\ud83d\udd34 **DISK CRITICAL:** ' + diskPct + '% used (threshold: ' + DISK_CRIT + '%)');\nif (memPct >= MEM_CRIT) alerts.push('\ud83d\udd34 **MEMORY CRITICAL:** ' + memPct + '% used (threshold: ' + MEM_CRIT + '%)');\nif (inodePct >= INODE_CRIT) alerts.push('\ud83d\udd34 **INODE CRITICAL:** ' + inodePct + '% used');\nif (load > cores * LOAD_MULT) alerts.push('\ud83d\udfe1 **HIGH CPU LOAD:** ' + load.toFixed(1) + ' (' + cores + ' cores, threshold: ' + (cores * LOAD_MULT).toFixed(1) + ')');\nfor (const c of downContainers) alerts.push('\ud83d\udd34 **CONTAINER DOWN:** ' + c);\nfor (const r of highRestarts) alerts.push('\ud83d\udfe1 **RESTART LOOP:** ' + r.name + ' has restarted ' + r.count + ' times');\n\nreturn { json: { has_critical: alerts.length > 0, alert_count: alerts.length, alerts: alerts, alert_message: alerts.join('\\n\\n'), disk_pct: diskPct, mem_pct: memPct, load: load, containers_down: downContainers.length } };"
      },
      "typeVersion": 2
    },
    {
      "id": "if-critical",
      "name": "Any critical issues found?",
      "type": "n8n-nodes-base.if",
      "position": [
        1056,
        784
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "loose"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "crit-check",
              "operator": {
                "type": "boolean",
                "operation": "equals"
              },
              "leftValue": "={{ $json.has_critical }}",
              "rightValue": true
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "format-alert",
      "name": "Format critical alert message",
      "type": "n8n-nodes-base.code",
      "position": [
        1296,
        768
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const data = $json;\nconst payload = {\n  content: '\ud83d\udea8 **HOMELAB CRITICAL ALERT**',\n  embeds: [{\n    title: data.alert_count + ' Critical Issue(s) Detected',\n    description: data.alert_message.substring(0, 4000),\n    color: 0xff3333,\n    fields: [\n      { name: 'Disk', value: data.disk_pct + '%', inline: true },\n      { name: 'Memory', value: data.mem_pct + '%', inline: true },\n      { name: 'CPU Load', value: data.load.toFixed(1), inline: true }\n    ],\n    timestamp: new Date().toISOString()\n  }]\n};\nreturn { json: payload };"
      },
      "typeVersion": 2
    },
    {
      "id": "send-alert",
      "name": "Send critical alert",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 3,
      "position": [
        1536,
        768
      ],
      "parameters": {
        "url": "={{ $('\u2699\ufe0f Alert settings').first().json.discord_webhook_url }}",
        "method": "POST",
        "options": {
          "timeout": 10000
        },
        "jsonBody": "={{ JSON.stringify($json) }}",
        "sendBody": true,
        "specifyBody": "json"
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 2000
    },
    {
      "id": "sticky-setup",
      "name": "Sticky Note - First-Time Setup",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        128,
        1088
      ],
      "parameters": {
        "color": 7,
        "width": 1120,
        "height": 364,
        "content": "## \ud83d\ude80 First-Time Setup\nClick \"Test workflow\" on the trigger below to auto-create a formatted \"Homelab Health Dashboard\" Google Sheet with color-coded metrics tracking, frozen headers, and conditional formatting. Copy the Sheet ID from the output into your Config node. Only needs to run once."
      },
      "typeVersion": 1
    },
    {
      "id": "setup-trigger",
      "name": "\u25b6\ufe0f Run first-time setup",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        208,
        1264
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "alert-config",
      "name": "\u2699\ufe0f Alert settings",
      "type": "n8n-nodes-base.set",
      "position": [
        384,
        784
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "alert-webhook",
              "name": "discord_webhook_url",
              "type": "string",
              "value": "https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN"
            },
            {
              "id": "alert-disk",
              "name": "disk_critical_pct",
              "type": "number",
              "value": 90
            },
            {
              "id": "alert-mem",
              "name": "memory_critical_pct",
              "type": "number",
              "value": 95
            },
            {
              "id": "alert-inode",
              "name": "inode_critical_pct",
              "type": "number",
              "value": 90
            },
            {
              "id": "alert-load",
              "name": "load_multiplier",
              "type": "number",
              "value": 1.5
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "setup-create",
      "name": "Create health dashboard spreadsheet",
      "type": "n8n-nodes-base.googleSheets",
      "onError": "continueRegularOutput",
      "position": [
        448,
        1264
      ],
      "parameters": {
        "title": "Homelab Health Dashboard",
        "options": {},
        "resource": "spreadsheet",
        "sheetsUi": {
          "sheetValues": [
            {
              "title": "metrics"
            }
          ]
        }
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.5
    },
    {
      "id": "setup-layout",
      "name": "Build dashboard layout",
      "type": "n8n-nodes-base.code",
      "position": [
        688,
        1264
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const input = $json;\nconst spreadsheetId = input.spreadsheetId;\nconst spreadsheetUrl = input.spreadsheetUrl;\n\nconst sheets = input.sheets || [];\nconst metricsSheet = sheets.find(s => s.properties && s.properties.title === 'metrics');\nconst sheetId = metricsSheet ? metricsSheet.properties.sheetId : 0;\n\nconst headers = [\n  'date', 'hostname', 'cpu_percent', 'cpu_load', 'memory_percent',\n  'disk_percent', 'inode_percent', 'swap_percent', 'health_score',\n  'containers_running', 'containers_total', 'net_rx_bytes', 'net_tx_bytes',\n  'docker_reclaimable_gb', 'zombie_count', 'connections'\n];\n\nconst headerCells = headers.map(h => ({\n  userEnteredValue: { stringValue: h },\n  userEnteredFormat: {\n    backgroundColor: { red: 0.15, green: 0.15, blue: 0.18 },\n    textFormat: {\n      bold: true,\n      fontSize: 10,\n      foregroundColor: { red: 0.90, green: 0.90, blue: 0.93 }\n    },\n    horizontalAlignment: 'CENTER',\n    borders: {\n      bottom: { style: 'SOLID', width: 2, color: { red: 0.20, green: 0.60, blue: 0.86 } }\n    }\n  }\n}));\n\nconst columnWidths = [100, 120, 90, 85, 115, 95, 95, 95, 100, 135, 120, 110, 110, 140, 100, 100];\n\nconst requests = [\n  {\n    updateCells: {\n      rows: [{ values: headerCells }],\n      start: { sheetId: sheetId, rowIndex: 0, columnIndex: 0 },\n      fields: 'userEnteredValue,userEnteredFormat'\n    }\n  },\n  {\n    updateSheetProperties: {\n      properties: { sheetId: sheetId, gridProperties: { frozenRowCount: 1 } },\n      fields: 'gridProperties.frozenRowCount'\n    }\n  },\n  {\n    updateSheetProperties: {\n      properties: { sheetId: sheetId, tabColorStyle: { rgbColor: { red: 0.20, green: 0.60, blue: 0.86 } } },\n      fields: 'tabColorStyle'\n    }\n  },\n  // Health score gradient (col 8)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 8, endColumnIndex: 9 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '65' },\n          maxpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 0\n    }\n  },\n  // CPU % gradient (col 2)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 2, endColumnIndex: 3 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '50' },\n          maxpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 1\n    }\n  },\n  // Memory % gradient (col 4)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 4, endColumnIndex: 5 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '80' },\n          maxpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 2\n    }\n  },\n  // Disk % gradient (col 5)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 5, endColumnIndex: 6 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '75' },\n          maxpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 3\n    }\n  },\n  // Inode % gradient (col 6)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 6, endColumnIndex: 7 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '80' },\n          maxpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 4\n    }\n  },\n  // Swap % gradient (col 7)\n  {\n    addConditionalFormatRule: {\n      rule: {\n        ranges: [{ sheetId: sheetId, startRowIndex: 1, startColumnIndex: 7, endColumnIndex: 8 }],\n        gradientRule: {\n          minpoint: { color: { red: 0.56, green: 0.87, blue: 0.56 }, type: 'NUMBER', value: '0' },\n          midpoint: { color: { red: 1.0, green: 0.85, blue: 0.40 }, type: 'NUMBER', value: '50' },\n          maxpoint: { color: { red: 0.92, green: 0.60, blue: 0.60 }, type: 'NUMBER', value: '100' }\n        }\n      },\n      index: 5\n    }\n  }\n];\n\ncolumnWidths.forEach((width, i) => {\n  requests.push({\n    updateDimensionProperties: {\n      range: { sheetId: sheetId, dimension: 'COLUMNS', startIndex: i, endIndex: i + 1 },\n      properties: { pixelSize: width },\n      fields: 'pixelSize'\n    }\n  });\n});\n\nreturn {\n  json: {\n    spreadsheetId,\n    spreadsheetUrl,\n    batchUpdateBody: { requests },\n    sheet_id: spreadsheetId,\n    sheet_url: spreadsheetUrl\n  }\n};"
      },
      "typeVersion": 2
    },
    {
      "id": "setup-format",
      "name": "Format spreadsheet as dashboard",
      "type": "n8n-nodes-base.httpRequest",
      "maxTries": 2,
      "position": [
        928,
        1264
      ],
      "parameters": {
        "url": "={{ \"https://sheets.googleapis.com/v4/spreadsheets/\" + $json.spreadsheetId + \":batchUpdate\" }}",
        "method": "POST",
        "options": {},
        "jsonBody": "={{ JSON.stringify($json.batchUpdateBody) }}",
        "sendBody": true,
        "specifyBody": "json",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "googleSheetsOAuth2Api"
      },
      "credentials": {
        "googleSheetsOAuth2Api": {
          "name": "<your credential>"
        }
      },
      "retryOnFail": true,
      "typeVersion": 4.2,
      "waitBetweenTries": 1000
    },
    {
      "id": "prepare-row",
      "name": "Prepare metrics row for storage",
      "type": "n8n-nodes-base.code",
      "position": [
        2320,
        368
      ],
      "parameters": {
        "jsCode": "const metrics = $('Parse and normalize all metrics').first().json;\nconst dk = metrics.docker_disk || {};\n\n// Parse reclaimable to GB\nlet reclaimableGb = 0;\nconst reclaimStr = dk.images_reclaimable || '';\nif (reclaimStr.includes('GB')) {\n  reclaimableGb = parseFloat(reclaimStr) || 0;\n} else if (reclaimStr.includes('MB')) {\n  reclaimableGb = (parseFloat(reclaimStr) || 0) / 1024;\n}\n\nreturn [{\n  json: {\n    date: metrics.timestamp.substring(0, 10),\n    hostname: metrics.hostname,\n    cpu_percent: metrics.cpu_percent,\n    cpu_load: metrics.cpu_load_1m,\n    memory_percent: metrics.memory_percent,\n    disk_percent: metrics.disk_percent,\n    inode_percent: metrics.inode_percent,\n    swap_percent: metrics.swap_percent,\n    health_score: metrics.health_score,\n    containers_running: metrics.containers_running,\n    containers_total: metrics.containers_total,\n    net_rx_bytes: metrics.net_rx_bytes,\n    net_tx_bytes: metrics.net_tx_bytes,\n    docker_reclaimable_gb: Math.round(reclaimableGb * 100) / 100,\n    zombie_count: metrics.zombie_count,\n    connections: metrics.connections_established\n  }\n}];"
      },
      "typeVersion": 2
    }
  ],
  "active": false,
  "settings": {
    "timezone": "America/Chicago",
    "callerPolicy": "workflowsFromSameOwner",
    "errorWorkflow": "",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "e8908ee6-2a41-4078-a940-e54d372bc9ec",
  "connections": {
    "OpenAI GPT-4o-mini": {
      "ai_languageModel": [
        [
          {
            "node": "Generate daily health digest",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "\u2699\ufe0f Alert settings": {
      "main": [
        [
          {
            "node": "Quick system health check",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build dashboard layout": {
      "main": [
        [
          {
            "node": "Format spreadsheet as dashboard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect system metrics": {
      "main": [
        [
          {
            "node": "Collect Docker container metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run daily health check": {
      "main": [
        [
          {
            "node": "\u2699\ufe0f Configure monitoring settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check for critical issues": {
      "main": [
        [
          {
            "node": "\u2699\ufe0f Alert settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format digest for Discord": {
      "main": [
        [
          {
            "node": "Send daily digest to Discord",
            "type": "main",
            "index": 0
          },
          {
            "node": "Prepare metrics row for storage",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Quick system health check": {
      "main": [
        [
          {
            "node": "Check against critical thresholds",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any critical issues found?": {
      "main": [
        [
          {
            "node": "Format critical alert message",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "\u25b6\ufe0f Run first-time setup": {
      "main": [
        [
          {
            "node": "Create health dashboard spreadsheet",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Generate daily health digest": {
      "main": [
        [
          {
            "node": "Format digest for Discord",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Format critical alert message": {
      "main": [
        [
          {
            "node": "Send critical alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse and normalize all metrics": {
      "main": [
        [
          {
            "node": "Read metrics history (last 7 days)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare metrics row for storage": {
      "main": [
        [
          {
            "node": "Store today's metrics in history",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Collect Docker container metrics": {
      "main": [
        [
          {
            "node": "Parse and normalize all metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check against critical thresholds": {
      "main": [
        [
          {
            "node": "Any critical issues found?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build analysis prompt with history": {
      "main": [
        [
          {
            "node": "Generate daily health digest",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read metrics history (last 7 days)": {
      "main": [
        [
          {
            "node": "Build analysis prompt with history",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create health dashboard spreadsheet": {
      "main": [
        [
          {
            "node": "Build dashboard layout",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "\u2699\ufe0f Configure monitoring settings": {
      "main": [
        [
          {
            "node": "Collect system metrics",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}