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 => {