From a82acb3f3a3990f61499a89ce0080e003da0cb28 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 10:08:30 -0800 Subject: [PATCH 01/12] Start with impl of mergeStrings --- .dir-locals.el | 4 + package-lock.json | 29 +++++- package.json | 1 + src/utils.js | 247 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 .dir-locals.el 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/package-lock.json b/package-lock.json index e7d678e..1c9f44b 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", @@ -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..23e5448 100644 --- a/package.json +++ b/package.json @@ -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..00526b3 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,3 +1,250 @@ +const jsdiff = require('diff'); + +const segmentString = (string) => { + return Array.from(string.matchAll(/[a-z0-9-]+|[^a-z0-9-]+/gi).map(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; + } + } + } + // Note that even though the ranges are inclusive-exclusive, + // we are treating them as inclusive-inclusive here because we + // want adjacent substrings to be picked up, as well. + 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); + } + + let varyingIdx = 0, unchangedIdx = 0; + let onVarying = varyingRanges[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(""); +} + +// console.log(mergeStrings([ +// "ArgoCD app stage1 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", +// "ArgoCD app stage2 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", +// "ArgoCD app stage1 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", +// "ArgoCD app stage1 sync status is Unknown in cluster staging/edge-na-rosy-gift", +// ])) + +console.log(mergeStrings([ + "ArgoCD app stage1 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage2 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage3 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage1 sync status is Unknown in cluster staging/edge-na-rosy-gift", + "ArgoCD app stage2 sync status is Unknown in cluster staging/edge-na-rosy-gift", + "ArgoCD app stage3 sync status is Unknown in cluster staging/edge-na-rosy-gift", + "ArgoCD app stage1 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage2 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage3 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", + "ArgoCD app stage1 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", + "ArgoCD app stage2 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", + "ArgoCD app stage3 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", +])) + const utils = { getRoomForReceiver: receiver => { From 7ba87bb4e2cdcab38e37db16c10885c97c69bd54 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 10:30:31 -0800 Subject: [PATCH 02/12] Start scaffolding group_by codepath --- src/utils.js | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/utils.js b/src/utils.js index 00526b3..ceec834 100644 --- a/src/utils.js +++ b/src/utils.js @@ -223,28 +223,6 @@ const mergeStrings = (strings) => { return combined.join(""); } -// console.log(mergeStrings([ -// "ArgoCD app stage1 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", -// "ArgoCD app stage2 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", -// "ArgoCD app stage1 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", -// "ArgoCD app stage1 sync status is Unknown in cluster staging/edge-na-rosy-gift", -// ])) - -console.log(mergeStrings([ - "ArgoCD app stage1 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage2 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage3 sync status is Unknown in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage1 sync status is Unknown in cluster staging/edge-na-rosy-gift", - "ArgoCD app stage2 sync status is Unknown in cluster staging/edge-na-rosy-gift", - "ArgoCD app stage3 sync status is Unknown in cluster staging/edge-na-rosy-gift", - "ArgoCD app stage1 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage2 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage3 sync status is OutOfSync in cluster prod/edge-eu-cuddly-pants", - "ArgoCD app stage1 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", - "ArgoCD app stage2 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", - "ArgoCD app stage3 sync status is OutOfSync in cluster staging/edge-na-rosy-gift", -])) - const utils = { getRoomForReceiver: receiver => { @@ -437,6 +415,14 @@ const utils = { return parts.join(' ') }, + formatAlerts: data => { + let summaries = data.alerts.map( + alert => alert.annotations.summary || alert.labels.alertname, + ); + let summary = mergeStrings(summaries); + return summary; + }, + parseAlerts: data => { /* Parse AlertManager data object into an Array of message strings. @@ -447,6 +433,10 @@ const utils = { console.log(JSON.stringify(data)) + if (process.env.RESPECT_GROUPBY === "1") { + return [utils.formatAlerts(data)] + } + let alerts = [] data.alerts.forEach(alert => { From bbe5df9c879b25b5b24e6e66de90520edb88a741 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 12:38:19 -0800 Subject: [PATCH 03/12] Write a bunch of code --- src/utils.js | 140 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 5 deletions(-) diff --git a/src/utils.js b/src/utils.js index ceec834..e5ff2ca 100644 --- a/src/utils.js +++ b/src/utils.js @@ -223,6 +223,12 @@ const mergeStrings = (strings) => { 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 => { @@ -416,11 +422,135 @@ const utils = { }, formatAlerts: data => { - let summaries = data.alerts.map( - alert => alert.annotations.summary || alert.labels.alertname, - ); - let summary = mergeStrings(summaries); - return summary; + 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, summary: alert.annotations.summary || alert.labels.alertname, + })) + ); + + 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}`); + } + + 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}`); + } + parts.push(`
${nbsp}
`); + alertNum += 1; + } + } + + parts.push(`
`); + } + + const urls = []; + + if (process.env.GRAFANA_URL) { + 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 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: process.env.GRAFANA_DATASOURCE, + queries: [{ + refId: "A", + expr: new URL(`fake:${generatorURL}`).searchParams.get("g0.expr"), + }], + range: { + "from": windowStarts.toISOString(), + "to": windowEnds.toISOString(), + }, + }; + const url = ( + process.env.GRAFANA_URL + + "/explore?orgId=1&left=" + + encodeURIComponent(JSON.stringify(left)) + ); + const name = generatorURLs.size > 1 ? `Grafana ${grafanaNum}` : "Grafana"; + 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(`๐Ÿ”‡ Alertmanager`); + } + + if (urls.length > 0) { + parts.push(urls.join(" | ")); + } + + return parts.join(""); }, parseAlerts: data => { From 7ec982995bf1aa7214004beeb67905380a1e5f8f Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 13:00:24 -0800 Subject: [PATCH 04/12] Handle annotations --- src/utils.js | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/utils.js b/src/utils.js index e5ff2ca..d5be74c 100644 --- a/src/utils.js +++ b/src/utils.js @@ -472,6 +472,15 @@ const utils = { parts.push(`
${label}: ${value}`); } + let hasCommonAnnotation = false; + for (const [annotation, value] of Object.entries(data.commonAnnotations)) { + if (!hasCommonAnnotation) { + parts.push(`
`); + hasCommonAnnotation; + } + parts.push(`
${annotation}: ${value}`); + } + if (alerts.length > 1) { parts.push(`
${nbsp}`); let alertNum = 1; @@ -489,6 +498,21 @@ const utils = { if (data.commonLabels[label]) continue; parts.push(`
${label}: ${value}`); } + let hasAnnotation = false; + for (const [annotation, value] of Object.entries(alert.annotations)) { + if (data.commonAnnotations[annotation]) continue; + if ( + annotation === "summary" || + annotation.startsWith("logs_") + ) { + continue; + } + if (!hasAnnotation) { + parts.push(`
`); + hasAnnotation = true; + } + parts.push(`
${annotation}: ${value}`); + } parts.push(`
${nbsp}`); alertNum += 1; } @@ -499,7 +523,7 @@ const utils = { const urls = []; - if (process.env.GRAFANA_URL) { + 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) { @@ -527,7 +551,7 @@ const utils = { "/explore?orgId=1&left=" + encodeURIComponent(JSON.stringify(left)) ); - const name = generatorURLs.size > 1 ? `Grafana ${grafanaNum}` : "Grafana"; + const name = generatorURLs.size > 1 ? `Alert query ${grafanaNum}` : "Alert query"; urls.push(`๐Ÿ“ˆ ${name}`); grafanaNum += 1; } @@ -543,7 +567,7 @@ const utils = { encodeURIComponent(filter) + "}" ); - urls.push(`๐Ÿ”‡ Alertmanager`); + urls.push(`๐Ÿ”‡ Silence`); } if (urls.length > 0) { From 3502403b33446327ebd39018cfdaf04a351788ea Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 13:50:15 -0800 Subject: [PATCH 05/12] Add the rest of the special URLs --- src/utils.js | 150 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 118 insertions(+), 32 deletions(-) diff --git a/src/utils.js b/src/utils.js index d5be74c..e6494ef 100644 --- a/src/utils.js +++ b/src/utils.js @@ -429,9 +429,24 @@ const utils = { const alerts = ( data.alerts .filter(alert => alert.status === status) - .map(alert => ({ - ...alert, summary: alert.annotations.summary || alert.labels.alertname, - })) + .map(alert => { + alert = {...alert}; + alert.summary = alert.annotations.summary || alert.labels.alertname; + if (!alert.labels.logs_url && !alert.labels.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.logs_template = ( + "{" + labelSet.map(l => l + `=$` + l).join(",") + "}" + ); + break; + } + } + } + return alert; + }) ); let summary = mergeStrings(alerts.map(alert => alert.summary)); @@ -472,8 +487,23 @@ const utils = { 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; @@ -500,13 +530,8 @@ const utils = { } let hasAnnotation = false; for (const [annotation, value] of Object.entries(alert.annotations)) { + if (omitAnnotation(annotation)) continue; if (data.commonAnnotations[annotation]) continue; - if ( - annotation === "summary" || - annotation.startsWith("logs_") - ) { - continue; - } if (!hasAnnotation) { parts.push(`
`); hasAnnotation = true; @@ -523,34 +548,40 @@ const utils = { 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 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: process.env.GRAFANA_DATASOURCE, - queries: [{ - refId: "A", - expr: new URL(`fake:${generatorURL}`).searchParams.get("g0.expr"), - }], - range: { - "from": windowStarts.toISOString(), - "to": windowEnds.toISOString(), - }, - }; - const url = ( - process.env.GRAFANA_URL + - "/explore?orgId=1&left=" + - encodeURIComponent(JSON.stringify(left)) - ); + 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; @@ -570,6 +601,61 @@ const utils = { urls.push(`๐Ÿ”‡ Silence`); } + const dashboardURLs = new Set(data.alerts + .map(alert => alert.annotations.dashboard_url) + .filter(Boolean)); + let dashboardNum = 1; + for (const dashboardURL of dashboardURLs) { + const name = dashboardURLs.size > 1 ? `Dashboard ${dashboardNum}` : "Dashboard"; + urls.push(` 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(` Date: Wed, 10 Dec 2025 14:16:41 -0800 Subject: [PATCH 06/12] Fix up last bugs --- src/utils.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/utils.js b/src/utils.js index e6494ef..cdac645 100644 --- a/src/utils.js +++ b/src/utils.js @@ -438,8 +438,8 @@ const utils = { ["env", "cluster_id", "nodename", "exported_job", "level"], ]) { if (labelSet.every(l => alert.labels[l])) { - alert.logs_template = ( - "{" + labelSet.map(l => l + `=$` + l).join(",") + "}" + alert.annotations.logs_template = ( + "{" + labelSet.map(l => `${l}="$${l}"`).join(",") + "}" ); break; } @@ -637,8 +637,10 @@ const utils = { ); for (const logsDatasource of logsDatasources) { const alerts = data.alerts.filter( - alert.annotations.logs_template === logsTemplate && - alert.annotations.logs_datasource || defaultDatasource === logsDatasource + alert => ( + alert.annotations.logs_template === logsTemplate && + alert.annotations.logs_datasource || defaultDatasource === logsDatasource + ), ); const expr = logsTemplate.replace(/=~?"\$([a-z0-9_]+)"/g, function(_, label) { const values = new Set(alerts.map(alert => alert.labels[label]).filter(Boolean)); @@ -653,7 +655,7 @@ const utils = { let logsNum = 1; for (const logsURL of logsURLs) { const name = logsURLs.size > 1 ? `Logs ${logsNum}` : "Logs"; - urls.push(`๐Ÿชต ${name}`); } if (urls.length > 0) { From 21e50447a2ede260966d7a3dca0530776e99ad29 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 14:48:10 -0800 Subject: [PATCH 07/12] More improvements --- src/utils.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/utils.js b/src/utils.js index cdac645..8d9f0a8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ const jsdiff = require('diff'); const segmentString = (string) => { - return Array.from(string.matchAll(/[a-z0-9-]+|[^a-z0-9-]+/gi).map(match => match[0])); + return Array.from(string.matchAll(/[a-z0-9.-]+|[^a-z0-9.-]+/gi).map(match => match[0])); } const diffSegmented = (left, right) => { @@ -432,6 +432,9 @@ const utils = { .map(alert => { alert = {...alert}; alert.summary = alert.annotations.summary || alert.labels.alertname; + if (alert.labels.env) { + alert.summary += ` (${alert.labels.env})`; + } if (!alert.labels.logs_url && !alert.labels.logs_template) { for (const labelSet of [ ["env", "cluster_id", "namespace", "pod"], @@ -605,9 +608,20 @@ const utils = { .map(alert => alert.annotations.dashboard_url) .filter(Boolean)); let dashboardNum = 1; - for (const dashboardURL of dashboardURLs) { + 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; } @@ -617,7 +631,7 @@ const utils = { 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 @@ -642,7 +656,7 @@ const utils = { alert.annotations.logs_datasource || defaultDatasource === logsDatasource ), ); - const expr = logsTemplate.replace(/=~?"\$([a-z0-9_]+)"/g, function(_, label) { + 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}"`; From be22915c30833384edacb3684901b9c75b88c3ec Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 15:38:24 -0800 Subject: [PATCH 08/12] Misc improvements --- src/utils.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/utils.js b/src/utils.js index 8d9f0a8..6f847a5 100644 --- a/src/utils.js +++ b/src/utils.js @@ -155,9 +155,13 @@ const mergeStrings = (strings) => { } } } - // Note that even though the ranges are inclusive-exclusive, - // we are treating them as inclusive-inclusive here because we - // want adjacent substrings to be picked up, as well. + // 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) { @@ -193,6 +197,11 @@ const mergeStrings = (strings) => { 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 = varyingRanges[0].start === 0; @@ -509,7 +518,7 @@ const utils = { if (omitAnnotation(annotation)) continue; if (!hasCommonAnnotation) { parts.push(`
`); - hasCommonAnnotation; + hasCommonAnnotation = true; } parts.push(`
${annotation}: ${value}`); } @@ -653,7 +662,7 @@ const utils = { const alerts = data.alerts.filter( alert => ( alert.annotations.logs_template === logsTemplate && - alert.annotations.logs_datasource || defaultDatasource === logsDatasource + (alert.annotations.logs_datasource || defaultDatasource) === logsDatasource ), ); const expr = logsTemplate.replace(/=~?"\$([a-z0-9_]+)"/g, (_, label) => { From ca458e4db79ec3f3e1d2e00ad1658f86f10d97de Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 15:41:28 -0800 Subject: [PATCH 09/12] Update nodejs runtime --- Dockerfile | 2 +- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 186f91e..58dbcb1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:14 +FROM node:24 ENV APP_PORT="3000" diff --git a/package-lock.json b/package-lock.json index 1c9f44b..dc258c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "sinon": "^11.1.2" }, "engines": { - "node": ">= 14" + "node": ">= 22" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 23e5448..8690147 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "bot" ], "engines": { - "node": ">= 14" + "node": ">= 22" }, "author": "Jason Robinson", "license": "MIT", From a381a7cb6766f6aa9a2d276859b49b9b28aae003 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Wed, 10 Dec 2025 15:44:28 -0800 Subject: [PATCH 10/12] Fix another bug --- src/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.js b/src/utils.js index 6f847a5..17c91fd 100644 --- a/src/utils.js +++ b/src/utils.js @@ -444,7 +444,7 @@ const utils = { if (alert.labels.env) { alert.summary += ` (${alert.labels.env})`; } - if (!alert.labels.logs_url && !alert.labels.logs_template) { + 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"], From a0f72660f73c8930a65d8a332da46bf47b5a0f5b Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Thu, 11 Dec 2025 09:42:36 -0800 Subject: [PATCH 11/12] Use the alpine variant --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 58dbcb1..4f3dc5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:24 +FROM node:24-alpine ENV APP_PORT="3000" From c89a33a75c52a62d1756aefd0749cda24c8ab435 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Thu, 11 Dec 2025 09:58:49 -0800 Subject: [PATCH 12/12] Changes --- src/utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/utils.js b/src/utils.js index 17c91fd..2410aa6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,7 +1,7 @@ const jsdiff = require('diff'); const segmentString = (string) => { - return Array.from(string.matchAll(/[a-z0-9.-]+|[^a-z0-9.-]+/gi).map(match => match[0])); + return Array.from(string.matchAll(/[a-z0-9.-]+|[^a-z0-9.-]+/gi), match => match[0]); } const diffSegmented = (left, right) => { @@ -203,7 +203,7 @@ const mergeStrings = (strings) => { // corresponding slices from the underlying strings, in order to // construct the final merged text. let varyingIdx = 0, unchangedIdx = 0; - let onVarying = varyingRanges[0].start === 0; + let onVarying = unchangedRanges[0].start > 0; let combined = []; while ( @@ -665,6 +665,7 @@ const utils = { (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("|") : ".+";