diff --git a/.dir-locals.el b/.dir-locals.el new file mode 100644 index 0000000..de58c49 --- /dev/null +++ b/.dir-locals.el @@ -0,0 +1,4 @@ +((web-mode . (;; four space indentation for JS + (web-mode-code-indent-offset . 4) + ;; no consistent code formatting + (apheleia-inhibit . t)))) diff --git a/Dockerfile b/Dockerfile index 186f91e..4f3dc5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14 +FROM node:24-alpine ENV APP_PORT="3000" diff --git a/package-lock.json b/package-lock.json index e7d678e..dc258c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.5.0", "license": "MIT", "dependencies": { + "diff": "^8.0.2", "dotenv": "^10.0.0", "express": "^4.17.1", "matrix-js-sdk": "^12.5.0", @@ -23,7 +24,7 @@ "sinon": "^11.1.2" }, "engines": { - "node": ">= 14" + "node": ">= 22" } }, "node_modules/@babel/code-frame": { @@ -1480,10 +1481,10 @@ "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, "node_modules/diff": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", - "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", - "dev": true, + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", + "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3614,6 +3615,16 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, + "node_modules/mocha/node_modules/diff": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", + "integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/mocha/node_modules/glob": { "version": "7.1.7", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", @@ -5066,6 +5077,16 @@ "url": "https://opencollective.com/sinon" } }, + "node_modules/sinon/node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/sinon/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 20a0b6e..8690147 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "bot" ], "engines": { - "node": ">= 14" + "node": ">= 22" }, "author": "Jason Robinson", "license": "MIT", @@ -32,6 +32,7 @@ "sinon": "^11.1.2" }, "dependencies": { + "diff": "^8.0.2", "dotenv": "^10.0.0", "express": "^4.17.1", "matrix-js-sdk": "^12.5.0", diff --git a/src/utils.js b/src/utils.js index fd22138..2410aa6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,243 @@ +const jsdiff = require('diff'); + +const segmentString = (string) => { + return Array.from(string.matchAll(/[a-z0-9.-]+|[^a-z0-9.-]+/gi), match => match[0]); +} + +const diffSegmented = (left, right) => { + return jsdiff.diffArrays(segmentString(left), segmentString(right)).map(match => { + match.value = match.value.join(""); + match.count = match.value.length; + return match; + }); +} + +const mergeStrings = (strings) => { + if (!strings || strings.length === 0) { + throw new Error(`No strings to merge!`); + } + + if (strings.length === 1) { + return strings[0]; + } + + // Take one string and diff it against all the other strings. This + // will show us all the places where the strings differ from each + // other. + // + // We'll actually diff the string against every string including + // itself, the latter of which will give us an empty diff. This + // will be helpful later as we won't have to treat the first + // string as a special case. + const diffs = []; + for (const str of strings) { + diffs.push(diffSegmented(strings[0], str)); + } + + // Go through each of the diffs sequentially. For each diff, + // iterate through each character of the first string, and see + // what happened to that character when the diff was applied. If + // it remained unchanged, rather than being deleted or + // substituted, we'll note that down. This will give us, for each + // diff, a list of which characters from the first string were + // left unchanged. + const unchangedSets = []; + for (const diff of diffs) { + const unchanged = new Set(); + let ptr = 0; + for (const edit of diff) { + // We should advance ptr for either deletions or unchanged + // sections. However, only the latter should be noted down + // in our set for later. + if (!edit.added) { + for (let i = 0; i < edit.value.length; i++) { + if (!edit.removed) { + unchanged.add(ptr); + } + ptr += 1; + } + } + } + if (ptr !== strings[0].length) { + throw new Error(`Diff didn't cover length of original string!`); + } + unchangedSets.push(unchanged); + } + + // Now we have a set of unchanged characters from the original + // string, one set for each diff. We compute a set intersection to + // find which characters from the original string are unchanged in + // every diff no matter what. We can use these as anchors so that + // the only changes between the strings occur in between these + // anchors. + let unchangedCommon = unchangedSets[0]; + for (const unchanged of unchangedSets) { + unchangedCommon = unchangedCommon.intersection(unchanged); + } + + // This gives us a bag of integers, but what we really want is + // ranges, to be easier to process. The computed ranges are + // inclusive-exclusive, and we add one onto the iteration length + // to neatly take care of ranges that end on the last character. + let unchangedRanges = []; + let rangeActive = false, rangeStart = -1; + for (let ptr = 0; ptr < strings[0].length + 1; ptr++) { + if (unchangedCommon.has(ptr) && !rangeActive) { + rangeActive = true, rangeStart = ptr; + } else if (!unchangedCommon.has(ptr) && rangeActive) { + rangeActive = false, unchangedRanges.push({ + start: rangeStart, + end: ptr, + }); + } + } + + // We'll also invert those ranges, to obtain the ranges that + // represent the remaining characters that are different between + // some of the strings, as opposed to the characters that are the + // same between all of them. We add some fake ranges on to the + // front and back to make the computation simpler in case there + // are varying ranges at the front or back. + let unchangedRangesAugmented = [ + { + start: -1, + end: 0, + }, + ...unchangedRanges, + { + start: strings[0].length, + end: strings[0].length + 1, + }, + ]; + let varyingRanges = []; + for (let i = 0; i < unchangedRangesAugmented.length - 1; i++) { + let start = unchangedRangesAugmented[i].end; + let end = unchangedRangesAugmented[i + 1].start; + if (start !== end) { + varyingRanges.push({ start, end }); + } + } + + // Now that we have a list of character ranges that may vary in + // each string, we can perform the same iteration through each + // diff as before, except now collecting the actual character data + // for those ranges. + const varyingLists = []; + for (const diff of diffs) { + const chars = []; + let ptr = 0; + for (const edit of diff) { + // Again we should only advance ptr for deletions or + // unchanged sections. But now instead of just noting down + // unchanged sections, we are also noting down additions. + // We tag each section or character with the relevant + // value of ptr, so that we can later filter to find out + // whether it falls within one of the ranges we are + // interested in. + if (edit.added) { + for (let i = 0; i < edit.value.length; i++) { + chars.push({ + ptr: ptr, + value: edit.value[i], + countAdjacent: true, + }); + } + } else { + for (let i = 0; i < edit.value.length; i++) { + if (!edit.removed) { + chars.push({ + ptr: ptr, + value: edit.value[i], + countAdjacent: false, + }); + } + ptr += 1; + } + } + } + // We're now iterating through the picked-out characters and + // the varying text ranges in parallel, incrementing either + // one when the other has gotten too far ahead. Our goal is to + // select for the particular picked-out characters that either + // fall strictly within a varying range (for unchanged + // characters) or fall within-or-adjacent-to a varying range + // (for added characters). + const varyingParts = []; + let idx = 0, done = false; + for (const range of varyingRanges) { + if (chars[idx].ptr > range.end) { + continue; + } + while (chars[idx].ptr < range.start) { + idx += 1; + if (idx >= chars.length) { + done = true; + break; + } + } + if (done) { + break; + } + let varyingPart = []; + while (chars[idx].ptr <= range.end) { + if (chars[idx].ptr < range.end || chars[idx].countAdjacent) { + varyingPart.push(chars[idx].value); + } + idx += 1; + if (idx >= chars.length) { + done = true; + break; + } + } + varyingParts.push(varyingPart.join("")); + if (done) { + break; + } + } + varyingLists.push(varyingParts); + } + + // Finally, we iterate through the varying and unchanged text + // ranges in parallel, alternating between them (starting at + // whichever one covers index zero) and inserting the + // corresponding slices from the underlying strings, in order to + // construct the final merged text. + let varyingIdx = 0, unchangedIdx = 0; + let onVarying = unchangedRanges[0].start > 0; + + let combined = []; + while ( + onVarying + ? (varyingIdx < varyingRanges.length) + : (unchangedIdx < unchangedRanges.length) + ) { + if (onVarying) { + let variants = new Set(); + for (const list of varyingLists) { + variants.add(list[varyingIdx]); + } + combined.push("{" + [...variants].sort().join(", ") + "}"); + + varyingIdx += 1; + onVarying = false; + } else { + const range = unchangedRanges[unchangedIdx]; + combined.push(strings[0].slice(range.start, range.end)); + + unchangedIdx += 1; + onVarying = true; + } + } + + return combined.join(""); +} + +const colorize = (color, msg) => { + // Wrap it with two different ways of doing color, one of which + // works on Desktop and one of which works on Android. Yes I know. + return `${msg}` +} + const utils = { getRoomForReceiver: receiver => { @@ -190,6 +430,265 @@ const utils = { return parts.join(' ') }, + formatAlerts: data => { + let parts = []; + + let statuses = new Set(data.alerts.map(alert => alert.status)); + for (const status of [...statuses].sort()) { + const alerts = ( + data.alerts + .filter(alert => alert.status === status) + .map(alert => { + alert = {...alert}; + alert.summary = alert.annotations.summary || alert.labels.alertname; + if (alert.labels.env) { + alert.summary += ` (${alert.labels.env})`; + } + if (!alert.annotations.logs_url && !alert.annotations.logs_template) { + for (const labelSet of [ + ["env", "cluster_id", "namespace", "pod"], + ["env", "cluster_id", "nodename", "exported_job", "level"], + ]) { + if (labelSet.every(l => alert.labels[l])) { + alert.annotations.logs_template = ( + "{" + labelSet.map(l => `${l}="$${l}"`).join(",") + "}" + ); + break; + } + } + } + return alert; + }) + ); + + let summary = mergeStrings(alerts.map(alert => alert.summary)); + + let severities = new Set(alerts.map(alert => alert.labels.severity)); + + let unknownEmoji = "๐Ÿคจ"; + let severityEmojis = { + "critical": "๐Ÿ’ฅ", + "error": "๐Ÿšจ", + "warning": "โš ๏ธ", + "info": "โ„น๏ธ", + }; + let statusEmojis = { + "resolved": "โœ…", + }; + let nbsp = "ย "; + + let summaryEmoji = ""; + for (const [severity, emoji] of Object.entries(severityEmojis)) { + if (severities.has(severity)) { + summaryEmoji += emoji; + } + } + if (statusEmojis[status]) { + summaryEmoji = statusEmojis[status]; + } + if (!summaryEmoji) { + summaryEmoji = unknownEmoji; + } + + parts.push(`
`); + parts.push(``); + parts.push(`${summaryEmoji} ${status.toUpperCase()}: ${summary}`); + parts.push(``); + + for (const [label, value] of Object.entries(data.commonLabels)) { + parts.push(`
${label}: ${value}`); + } + + const omitAnnotation = (ann) => { + switch (ann) { + case "summary": + case "dashboard_url": + case "runbook_url": + case "logs_url": + case "logs_template": + case "logs_datasource": + return true; + default: + return false; + } + } + + let hasCommonAnnotation = false; + for (const [annotation, value] of Object.entries(data.commonAnnotations)) { + if (omitAnnotation(annotation)) continue; + if (!hasCommonAnnotation) { + parts.push(`
`); + hasCommonAnnotation = true; + } + parts.push(`
${annotation}: ${value}`); + } + + if (alerts.length > 1) { + parts.push(`
${nbsp}`); + let alertNum = 1; + for (const alert of alerts) { + const emoji = ( + statusEmojis[alert.status] || + severityEmojis[alert.labels.severity] || + unknownEmoji + ); + parts.push(`
`); + parts.push(``); + parts.push(`${emoji} ${alert.status.toUpperCase()}: ${alert.summary}`); + parts.push(``); + for (const [label, value] of Object.entries(alert.labels)) { + if (data.commonLabels[label]) continue; + parts.push(`
${label}: ${value}`); + } + let hasAnnotation = false; + for (const [annotation, value] of Object.entries(alert.annotations)) { + if (omitAnnotation(annotation)) continue; + if (data.commonAnnotations[annotation]) continue; + if (!hasAnnotation) { + parts.push(`
`); + hasAnnotation = true; + } + parts.push(`
${annotation}: ${value}`); + } + parts.push(`
${nbsp}
`); + alertNum += 1; + } + } + + parts.push(`
`); + } + + const urls = []; + + const makeGrafanaURL = (alerts, expr, datasource) => { + const relevantTimes = alerts.map(alert => new Date(alert.startsAt)); + relevantTimes.push(new Date()); + const minRelevant = Math.min.apply(null, relevantTimes), + maxRelevant = Math.max.apply(null, relevantTimes); + const thirtyMinutesMs = 30 * 60 * 1000; + const windowStarts = new Date(minRelevant - thirtyMinutesMs); + const windowEnds = new Date(maxRelevant + thirtyMinutesMs); + const left = { + datasource: datasource, + queries: [{ + refId: "A", + queryType: "range", + expr: expr, + }], + range: { + "from": windowStarts.toISOString(), + "to": windowEnds.toISOString(), + }, + }; + return ( + process.env.GRAFANA_URL + + "/explore?orgId=1&left=" + + encodeURIComponent(JSON.stringify(left)) + ); + } + + if (process.env.GRAFANA_URL && process.env.GRAFANA_DATASOURCE) { + const generatorURLs = new Set(data.alerts.map(alert => alert.generatorURL)); + let grafanaNum = 1; + for (const generatorURL of generatorURLs) { + const alerts = data.alerts.filter(alert => alert.generatorURL == generatorURL); + const expr = new URL(`fake:${generatorURL}`).searchParams.get("g0.expr"); + const url = makeGrafanaURL(alerts, expr, process.env.GRAFANA_DATASOURCE); + const name = generatorURLs.size > 1 ? `Alert query ${grafanaNum}` : "Alert query"; + urls.push(`๐Ÿ“ˆ ${name}`); + grafanaNum += 1; + } + } + + if (process.env.ALERTMANAGER_URL) { + let filter = Object.entries(data.commonLabels) + .map(([label, value]) => `${label}="${value}"`) + .join(","); + const url = ( + process.env.ALERTMANAGER_URL + + "/#/silences/new?filter={" + + encodeURIComponent(filter) + + "}" + ); + urls.push(`๐Ÿ”‡ Silence`); + } + + const dashboardURLs = new Set(data.alerts + .map(alert => alert.annotations.dashboard_url) + .filter(Boolean)); + let dashboardNum = 1; + for (let dashboardURL of dashboardURLs) { + // For now, we'll only support replacing labels with a + // single value. In future it would be straightforward to + // duplicate query parameters in the URL to support + // passing all the values to the dashboard. + const alerts = data.alerts.filter( + alert => alert.annotations.dashboard_url === dashboardURL, + ); + dashboardURL = dashboardURL.replace(/\$([a-z0-9_]+)/g, (_, label) => { + const values = new Set(alerts.map(alert => alert.labels[label]).filter(Boolean)); + return values.size > 0 ? [...values][0] : `$` + label; + }); + const name = dashboardURLs.size > 1 ? `Dashboard ${dashboardNum}` : "Dashboard"; + urls.push(`๐Ÿšฆ ${name}`); + dashboardNum += 1; + } + + const runbookURLs = new Set(data.alerts + .map(alert => alert.annotations.runbook_url) + .filter(Boolean)); + let runbookNum = 1; + for (const runbookURL of runbookURLs) { + const name = runbookURLs.size > 1 ? `Runbook ${runbookNum}` : "Runbook"; + urls.push(`๐Ÿ—’๏ธ ${name}`); + } + + let logsURLs = new Set(data.alerts + .map(alert => alert.annotations.logs_url) + .filter(Boolean)); + + if (process.env.GRAFANA_URL && process.env.GRAFANA_LOKI_DATASOURCE) { + const defaultDatasource = process.env.GRAFANA_LOKI_DATASOURCE; + const logsTemplates = new Set(data.alerts + .map(alert => alert.annotations.logs_template) + .filter(Boolean)); + for (const logsTemplate of logsTemplates) { + const logsDatasources = new Set( + data.alerts + .map(alert => alert.annotations.logs_datasource || defaultDatasource) + .filter(Boolean) + ); + for (const logsDatasource of logsDatasources) { + const alerts = data.alerts.filter( + alert => ( + alert.annotations.logs_template === logsTemplate && + (alert.annotations.logs_datasource || defaultDatasource) === logsDatasource + ), + ); + if (alerts.length === 0) continue; + const expr = logsTemplate.replace(/=~?"\$([a-z0-9_]+)"/g, (_, label) => { + const values = new Set(alerts.map(alert => alert.labels[label]).filter(Boolean)); + const regex = values.size > 0 ? [...values].join("|") : ".+"; + return `=~"${regex}"`; + }); + logsURLs.add(makeGrafanaURL(alerts, expr, logsDatasource)); + } + } + } + + let logsNum = 1; + for (const logsURL of logsURLs) { + const name = logsURLs.size > 1 ? `Logs ${logsNum}` : "Logs"; + urls.push(`๐Ÿชต ${name}`); + } + + if (urls.length > 0) { + parts.push(urls.join(" | ")); + } + + return parts.join(""); + }, + parseAlerts: data => { /* Parse AlertManager data object into an Array of message strings. @@ -200,6 +699,10 @@ const utils = { console.log(JSON.stringify(data)) + if (process.env.RESPECT_GROUPBY === "1") { + return [utils.formatAlerts(data)] + } + let alerts = [] data.alerts.forEach(alert => {