{ "name": "C - Nightly ops digest", "nodes": [ { "id": "c1", "name": "Every night at 07:00", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.2, "position": [ -460, 0 ], "parameters": { "rule": { "interval": [ { "field": "cronExpression", "expression": "0 7 * * *" } ] } } }, { "id": "60aab8c8", "name": "Forgejo repos", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ -240, 0 ], "parameters": { "method": "GET", "url": "http://forgejo:3000/api/v1/repos/search?limit=50", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Forgejo API" } } }, { "id": "c3", "name": "Repo list", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ -20, 0 ], "parameters": { "jsCode": "\n// One item per repository, so the next node runs once per repo.\nconst list = $json.data ?? [];\nreturn list.map(r => ({ json: { owner: r.owner?.login ?? r.full_name.split('/')[0],\n repo: r.name, full: r.full_name } }));\n" } }, { "id": "567ab14b", "name": "Commits per repo", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 200, 0 ], "parameters": { "method": "GET", "url": "=http://forgejo:3000/api/v1/repos/{{ $json.owner }}/{{ $json.repo }}/commits?limit=50&stat=false", "options": { "response": { "response": { "responseFormat": "text", "outputPropertyName": "data" } } }, "authentication": "genericCredentialType", "genericAuthType": "httpHeaderAuth" }, "credentials": { "httpHeaderAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "Forgejo API" } }, "onError": "continueRegularOutput" }, { "id": "4b0e6311", "name": "Kuma uptime", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 420, 0 ], "parameters": { "method": "GET", "url": "http://uptime-kuma:3001/api/status-page/heartbeat/status", "options": {} }, "executeOnce": true }, { "id": "1bb848e9", "name": "Kuma monitors", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 640, 0 ], "parameters": { "method": "GET", "url": "http://uptime-kuma:3001/api/status-page/status", "options": {} }, "executeOnce": true }, { "id": "c7", "name": "Read backup log", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [ 860, 0 ], "executeOnce": true, "parameters": { "fileSelector": "/backup/backup.log", "options": {} }, "onError": "continueRegularOutput" }, { "id": "c8", "name": "Log to text", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1, "position": [ 1080, 0 ], "parameters": { "operation": "text", "binaryPropertyName": "data", "options": {} }, "onError": "continueRegularOutput" }, { "id": "c9", "name": "Build digest", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [ 1300, 0 ], "parameters": { "jsCode": "\n// Everything below is read from the three services on this box. Nothing is\n// invented: if a source is missing the digest says so instead of guessing.\nconst repos = $('Repo list').all().map(i => i.json);\nconst bodies = $('Commits per repo').all().map(i => i.json);\nconst since = Date.now() - 24 * 60 * 60 * 1000;\n\nlet commitTotal = 0;\nconst perRepo = [];\nbodies.forEach((b, idx) => {\n let list = [];\n try { list = JSON.parse(b.data ?? '[]'); } catch (e) { list = []; }\n if (!Array.isArray(list)) list = [];\n const recent = list.filter(c => {\n const d = c.commit?.author?.date ?? c.commit?.committer?.date ?? c.created;\n return d && new Date(d).getTime() >= since;\n });\n commitTotal += recent.length;\n perRepo.push({\n repo: repos[idx]?.full ?? ('repo ' + idx),\n commits: recent.length,\n last: recent.length ? String(recent[0]?.commit?.message ?? '').split('\\n')[0].slice(0, 80) : '',\n });\n});\n\n// Uptime Kuma: the public status page carries both the names and the 24h numbers.\nconst hb = $('Kuma uptime').first().json ?? {};\nconst sp = $('Kuma monitors').first().json ?? {};\nconst names = {};\n(sp.publicGroupList ?? []).forEach(g => (g.monitorList ?? []).forEach(m => { names[String(m.id)] = m.name; }));\nconst uptime = Object.entries(hb.uptimeList ?? {}).map(([k, v]) => ({\n name: names[k.split('_')[0]] ?? ('monitor ' + k.split('_')[0]),\n pct: Math.round(Number(v) * 10000) / 100,\n}));\nconst worst = uptime.length ? Math.min(...uptime.map(u => u.pct)) : null;\n\n// Episode 7 writes one line per phase into the backup log. Read the last run.\nconst log = String($input.first().json.data ?? '');\nlet snap = null, files = null, size = null, took = null, when = null, beat = false;\nfor (const line of log.split('\\n')) {\n let m;\n if ((m = line.match(/^snapshot ([0-9a-f]+) saved/))) snap = m[1];\n if ((m = line.match(/processed (\\d+) files, ([\\d.]+ [KMG]iB) in/))) { files = m[1]; size = m[2]; }\n if ((m = line.match(/^(\\S+ \\S+) \\[5\\/5\\] done in (\\d+)s/))) { when = m[1]; took = m[2]; }\n if (line.includes('heartbeat sent to Uptime Kuma')) beat = true;\n}\nconst backup = snap\n ? 'snapshot `' + snap + '`, ' + files + ' files, ' + size + ', took ' + took + ' s, finished ' + when + (beat ? ', heartbeat sent' : ', NO heartbeat')\n : 'no backup log found on this box';\n\nconst today = new Date().toISOString().slice(0, 10);\nconst text = [\n '## Git',\n '',\n commitTotal + ' commit(s) in the last 24 hours across ' + perRepo.length + ' repositories.',\n '',\n ...perRepo.map(r => '- ' + r.repo + ': ' + r.commits + (r.last ? ', latest \"' + r.last + '\"' : '')),\n '',\n '## Availability (last 24 hours)',\n '',\n ...(uptime.length ? uptime.map(u => '- ' + u.name + ': ' + u.pct + ' %')\n : ['- no monitors on the public status page']),\n '',\n '## Backup',\n '',\n '- ' + backup,\n '',\n '---',\n '',\n 'Written by n8n, workflow \"C - Nightly ops digest\". Sources: Forgejo API,',\n 'Uptime Kuma status page API, the nightly restic log from episode 7.',\n].join('\\n');\n\nconst summary = commitTotal + ' commits, ' + (worst === null ? 'no uptime data' : 'uptime ' + worst + ' % worst') + ', backup ' + (snap ? 'ok' : 'MISSING');\n\nreturn [{ json: {\n commitTotal, perRepo, uptime, worst, backup, summary,\n payload: { title: 'Ops digest ' + today, text, collectionId: 'REPLACE_WITH_YOUR_OUTLINE_COLLECTION_ID', publish: true },\n}}];\n" } }, { "id": "b1f2cb7c", "name": "Write digest doc", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1520, 0 ], "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": "fc737f63", "name": "Push digest", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [ 1740, 0 ], "parameters": { "method": "POST", "url": "http://ntfy/deploys", "options": {}, "authentication": "genericCredentialType", "genericAuthType": "httpBasicAuth", "sendHeaders": true, "headerParameters": { "parameters": [ { "name": "X-Title", "value": "Nightly ops digest" }, { "name": "X-Priority", "value": "2" }, { "name": "X-Click", "value": "=https://wiki.ops.example.com{{ $json.data.url }}" } ] }, "sendBody": true, "contentType": "raw", "rawContentType": "text/plain", "body": "={{ $('Build digest').first().json.summary }}" }, "credentials": { "httpBasicAuth": { "id": "REPLACE_WITH_YOUR_CREDENTIAL_ID", "name": "ntfy (n8n user)" } } } ], "connections": { "Every night at 07:00": { "main": [ [ { "node": "Forgejo repos", "type": "main", "index": 0 } ] ] }, "Forgejo repos": { "main": [ [ { "node": "Repo list", "type": "main", "index": 0 } ] ] }, "Repo list": { "main": [ [ { "node": "Commits per repo", "type": "main", "index": 0 } ] ] }, "Commits per repo": { "main": [ [ { "node": "Kuma uptime", "type": "main", "index": 0 } ] ] }, "Kuma uptime": { "main": [ [ { "node": "Kuma monitors", "type": "main", "index": 0 } ] ] }, "Kuma monitors": { "main": [ [ { "node": "Read backup log", "type": "main", "index": 0 } ] ] }, "Read backup log": { "main": [ [ { "node": "Log to text", "type": "main", "index": 0 } ] ] }, "Log to text": { "main": [ [ { "node": "Build digest", "type": "main", "index": 0 } ] ] }, "Build digest": { "main": [ [ { "node": "Write digest doc", "type": "main", "index": 0 } ] ] }, "Write digest doc": { "main": [ [ { "node": "Push digest", "type": "main", "index": 0 } ] ] } }, "settings": { "executionOrder": "v1" }, "active": false }