{ "name": "B - Incident responder", "nodes": [ { "id": "b1", "name": "Kuma incident", "type": "n8n-nodes-base.webhook", "typeVersion": 2, "position": [ -220, 0 ], "webhookId": "REPLACE_ON_IMPORT", "parameters": { "httpMethod": "POST", "path": "kuma-incident", "responseMode": "onReceived", "options": {} } }, { "id": "b2", "name": "Read event", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 0, 0 ], "parameters": { "jsCode": "\n// Uptime Kuma posts one JSON body for every state change. Reduce it to the\n// four fields both branches need, so nothing below has to know Kuma's shape.\nconst b = $json.body ?? $json;\nconst hb = b.heartbeat ?? {};\nconst mon = b.monitor ?? {};\nconst down = Number(hb.status) === 0;\nreturn [{\n json: {\n monitor: mon.name ?? 'unknown monitor',\n monitorUrl: mon.url ?? '',\n down,\n at: hb.time ? new Date(hb.time.replace(' ', 'T') + 'Z').toISOString() : new Date().toISOString(),\n reason: hb.msg ?? b.msg ?? '',\n },\n}];\n" } }, { "id": "b3", "name": "Down?", "type": "n8n-nodes-base.if", "typeVersion": 2.2, "position": [ 220, 0 ], "parameters": { "conditions": { "options": { "caseSensitive": true, "leftValue": "", "typeValidation": "strict", "version": 2 }, "conditions": [ { "id": "c1", "leftValue": "={{ $json.down }}", "rightValue": "", "operator": { "type": "boolean", "operation": "true", "singleValue": true } } ], "combinator": "and" }, "options": {} } }, { "id": "b4", "name": "Build incident doc", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 460, -140 ], "parameters": { "jsCode": "\n// One incident document per outage. \"Status: open\" is the marker the UP\n// branch searches for later, so it stays machine readable on purpose.\nconst e = $json;\nconst stamp = e.at.replace('T', ' ').replace(/\\..*/, ' UTC');\nconst title = 'Incident: ' + e.monitor + ' (' + stamp + ')';\n// A markdown list, not plain lines: Outline collapses single newlines inside\n// a paragraph, which would turn the whole record into one unreadable blob.\nconst text = [\n '- Monitor: ' + e.monitor,\n '- Status: open',\n '- Down since: ' + e.at,\n '- Reason: ' + (e.reason || 'no detail from the monitor'),\n e.monitorUrl ? '- Checked URL: ' + e.monitorUrl : '',\n '',\n '---',\n '',\n 'Opened automatically by n8n. The resolution is appended when the monitor reports UP again.',\n].filter(Boolean).join('\\n');\nreturn [{ json: { title, payload: { title, text, collectionId: 'REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID', publish: true } } }];\n" } }, { "id": "13d2a02f", "name": "Create incident doc", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 680, -140 ], "parameters": { "method": "POST", "url": "http://outline:3000/api/documents.create", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.payload) }}" }, "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Outline API" } } }, { "id": "e28ddc1d", "name": "Push alert", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 900, -140 ], "parameters": { "method": "POST", "url": "http://ntfy/alerts", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Title", "value": "=DOWN: {{ $('Read event').first().json.monitor }}" }, { "name": "X-Priority", "value": "5" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "text/plain", "body": "={{ $('Read event').first().json.reason || 'monitor reports down' }}\nIncident doc: https://wiki.ops.example.com{{ $json.data.url }}" }, "credentials": { "httpBasicAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ntfy (n8n user)" } } }, { "id": "0ac14924", "name": "List Ops Log", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 460, 140 ], "parameters": { "method": "POST", "url": "http://outline:3000/api/documents.list", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify({ collectionId: \"REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID\", limit: 25 }) }}" }, "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Outline API" } } }, { "id": "b8", "name": "Find open incident", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 680, 140 ], "parameters": { "jsCode": "\n// Find the still open incident for this monitor and close it out.\nconst e = $('Read event').first().json;\nconst docs = $input.first().json.data ?? [];\nconst open = docs.find(d =>\n String(d.text ?? '').includes('Monitor: ' + e.monitor) &&\n String(d.text ?? '').includes('Status: open'));\n\nif (!open) {\n return [{ json: { found: false, monitor: e.monitor, duration: 'unknown',\n note: 'no open incident document for this monitor' } }];\n}\n\nconst m = String(open.text).match(/Down since:\\s*(\\S+)/);\nconst since = m ? new Date(m[1]) : null;\nconst back = new Date(e.at);\nconst secs = since ? Math.max(0, Math.round((back - since) / 1000)) : null;\nconst human = secs === null ? 'unknown'\n : secs < 90 ? secs + ' s'\n : Math.floor(secs / 60) + ' min ' + (secs % 60) + ' s';\n\nconst text = String(open.text).replace('Status: open', 'Status: resolved')\n + '\\n- Back up: ' + e.at + '\\n- Downtime: ' + human + '\\n';\n\nreturn [{ json: {\n found: true, monitor: e.monitor, duration: human, seconds: secs,\n docUrl: open.url ?? '',\n payload: { id: open.id, title: String(open.title).replace('Incident:', 'Resolved:'), text, publish: true },\n}}];\n" } }, { "id": "e402dfe1", "name": "Close incident doc", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 900, 140 ], "parameters": { "method": "POST", "url": "http://outline:3000/api/documents.update", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth", "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "={{ JSON.stringify($json.payload || {}) }}" }, "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Outline API" } }, "onError": "continueRegularOutput" }, { "id": "7b4981c4", "name": "Push all clear", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1120, 140 ], "parameters": { "method": "POST", "url": "http://ntfy/alerts", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Title", "value": "=UP again: {{ $('Read event').first().json.monitor }}" }, { "name": "X-Priority", "value": "3" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "text/plain", "body": "=Downtime {{ $('Find open incident').first().json.duration }}. Incident closed." }, "credentials": { "httpBasicAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ntfy (n8n user)" } } } ], "connections": { "Kuma incident": { "main": [ [ { "node": "Read event", "type": "main", "index": 0 } ] ] }, "Read event": { "main": [ [ { "node": "Down?", "type": "main", "index": 0 } ] ] }, "Down?": { "main": [ [ { "node": "Build incident doc", "type": "main", "index": 0 } ], [ { "node": "List Ops Log", "type": "main", "index": 0 } ] ] }, "Build incident doc": { "main": [ [ { "node": "Create incident doc", "type": "main", "index": 0 } ] ] }, "Create incident doc": { "main": [ [ { "node": "Push alert", "type": "main", "index": 0 } ] ] }, "List Ops Log": { "main": [ [ { "node": "Find open incident", "type": "main", "index": 0 } ] ] }, "Find open incident": { "main": [ [ { "node": "Close incident doc", "type": "main", "index": 0 } ] ] }, "Close incident doc": { "main": [ [ { "node": "Push all clear", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }, "active": false }