diff --git a/.gitignore b/.gitignore index 21f22a70ae23594913bfed26516ab580ad05ca6f..0d08c43e1db932724bb1829dcf2bb1641b12a7f4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .bash* .profile* env/ +.flaskenv \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..77d3693c1bbbfe6320959cb7bf7f4fd2cbdd76d4 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# Webhook to Matrix Hookshot + +This API translates incoming webhooks into generic webhooks (hookshot). + +1. Receive Grafana webhook alert +2. Prepare alert data (error handling, add additional values) +3. Apply data to selected template (html, text) +4. Send out notification to hookshot + +## Configuration + +`https://webhooks.mbot.ethz.ch/webhook/grafana/<hook>?template=<template>&version=<version>` + +| Param | Description | Optional | +| ---------- | ------------------------------- | -------- | +| `hook` | Hookshot ID of your webhook | | +| `template` | Template name (see table below) | Yes | +| `version` | API version (currently ignored) | Yes | + +This is the URL needed by Grafana. Create a new contact point and paste it. + +## How to run + +`flask --app app --debug run` + +Set hostname and port with `-h` and `-p` respectively. + +## Templates + +| Name | Description | +| ---------------- | --------------------------- | +| `default` | shows important values | +| `oneliner` | `default` but more concise | +| `detailed` | `default` but better | +| `detailed_table` | `detailed` with html tables | + +For mor details see [templates](/templates.md). + +## Jinja Templates + +| `<template_name>` | | +| ----------------- | --------------------------------------------------------- | +| Path HTML | `template/<template_name>.html.jinja` | +| Path Text | `template/<template_name>.txt.jinja` | +| Registration | Append `<template_name>` to `msg_templates` `(config.py)` | + +- Info + - Template rendering context (variables available in the template): + - Incoming grafana alert data + - Additional values (see below) + - Error handling: missing/invalid data is interpreted as empty/zero +- Keep in mind + - Use `alert['values']` (and not `alert.values`) to keep Jinja happy + - Only use html tags allowed by the matrix spec + - Remove unnecessary whitespace + +### Rendering Context + +You can use every value provided by Grafana. For a complete list see [Grafana Docs](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/). + +#### Reserved / Special Values + +These values come directly from Grafana but have a special meaning or are used to +compute additional values and are thus explained here. + +| | Value | Description | +| ------------------- | ---------------------------------- | ----------- | +| Reserved Labels | `alert.labels['alertname']` | | +| | `alert.labels['grafana_folder']` | | +| Special Annotations | `alert.annotations['description']` | | +| | `alert.annotations['summary]` | | +| Timestamps | `alert['startsAt']` | | +| | `alert['endsAt']` | | + +**Note:** Reserved labels / special annotations also apply to `commonLabels`, `commonAnnotations` + +#### Additional Values + +These values are added to the incoming Grafana alert which is then applied to the Jinja template + +```jsonc +{ + "normalLabels": {}, + "normalAnnotations": {}, + "alerts": [ + { + "uniqueLabels": {}, + "uniqueAnnotations": {}, + "valuesForJinja": {}, + "startsAtParsed": "", + "endsAtParsed": "", + }, + (...) + ], + "commonStartsAtParsed": "", + "commonEndsAtParsed": "", +} +``` + +| Value | Description | +| ---------------------- | ------------------------------------------------ | +| `normalLabels` | Labels that are not "reserved" | +| `normalAnnotations` | Annotations that are not "special" | +| `uniqueLabels` | Labels not in `commonLabels` | +| `uniqueAnnotations` | Annotations not in `commonAnnotations` | +| `startsAtParsed` | Formatted version of `startsAt` | +| `endsAtParsed` | Formatted version of `endsAt` | +| `commonStartsAtParsed` | `startsAtParsed` if same on every alert instance | +| `commonEndsAtParsed` | `endsAtParsed` if same on every alert instance | + +### Inherit Template + +Inherit from another text template if you only want to change html + +```jinja +{%- include "detailed.txt.jinja" -%} +``` + +## Links + +- [Matrix message spec](https://spec.matrix.org/latest/client-server-api/#mroommessage-msgtypes) +- [Hookshot webhook handling](https://matrix-org.github.io/matrix-hookshot/latest/setup/webhooks.html#webhook-handling) +- Grafana + - [Alerts webhook format](https://grafana.com/docs/grafana/latest/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/) + - [Labels and annotations](https://grafana.com/docs/grafana/next/alerting/fundamentals/alert-rules/annotation-label/) diff --git a/_screenshots/hookshot-create.png b/_screenshots/hookshot-create.png new file mode 100644 index 0000000000000000000000000000000000000000..8dbe31a6519ebeb9e3fc08229a0be063152ca703 Binary files /dev/null and b/_screenshots/hookshot-create.png differ diff --git a/_screenshots/templates/default-cpu_usage.html.png b/_screenshots/templates/default-cpu_usage.html.png new file mode 100644 index 0000000000000000000000000000000000000000..6ccadc0d65596b3276f34511885daea8388f04ec Binary files /dev/null and b/_screenshots/templates/default-cpu_usage.html.png differ diff --git a/_screenshots/templates/default.html.png b/_screenshots/templates/default.html.png new file mode 100644 index 0000000000000000000000000000000000000000..c6739695fa91c902b7cde59c240d06688a8325b3 Binary files /dev/null and b/_screenshots/templates/default.html.png differ diff --git a/_screenshots/templates/detailed-cpu_usage.html.png b/_screenshots/templates/detailed-cpu_usage.html.png new file mode 100644 index 0000000000000000000000000000000000000000..2f4f634bf62ca39bfa6beb23da592f24fb8f7dcc Binary files /dev/null and b/_screenshots/templates/detailed-cpu_usage.html.png differ diff --git a/_screenshots/templates/detailed.html.png b/_screenshots/templates/detailed.html.png new file mode 100644 index 0000000000000000000000000000000000000000..33d6c433b8324309da25965f15e94fed1ca77771 Binary files /dev/null and b/_screenshots/templates/detailed.html.png differ diff --git a/_screenshots/templates/detailed_table-cpu_usage0.html.png b/_screenshots/templates/detailed_table-cpu_usage0.html.png new file mode 100644 index 0000000000000000000000000000000000000000..3867e8b8de6d294005b62efc2c396babbf7214e7 Binary files /dev/null and b/_screenshots/templates/detailed_table-cpu_usage0.html.png differ diff --git a/_screenshots/templates/detailed_table-cpu_usage1.html.png b/_screenshots/templates/detailed_table-cpu_usage1.html.png new file mode 100644 index 0000000000000000000000000000000000000000..e7c5b788f3a652fe0bfed2f55c88ff1c38b3b7ac Binary files /dev/null and b/_screenshots/templates/detailed_table-cpu_usage1.html.png differ diff --git a/_screenshots/templates/detailed_table-humidity.html.png b/_screenshots/templates/detailed_table-humidity.html.png new file mode 100644 index 0000000000000000000000000000000000000000..a6dda6d9894502a7043a164ac1b143e68a00f6ef Binary files /dev/null and b/_screenshots/templates/detailed_table-humidity.html.png differ diff --git a/_screenshots/templates/detailed_table-temp.html.png b/_screenshots/templates/detailed_table-temp.html.png new file mode 100644 index 0000000000000000000000000000000000000000..28259ea17726d24157f30e8cf21c29946db97907 Binary files /dev/null and b/_screenshots/templates/detailed_table-temp.html.png differ diff --git a/_screenshots/templates/detailed_table-wetbulb.html.png b/_screenshots/templates/detailed_table-wetbulb.html.png new file mode 100644 index 0000000000000000000000000000000000000000..bbcbb8f427b2af497900e9198b8ae97fe8d846a9 Binary files /dev/null and b/_screenshots/templates/detailed_table-wetbulb.html.png differ diff --git a/_screenshots/templates/oneliner-cpu_usage.html.png b/_screenshots/templates/oneliner-cpu_usage.html.png new file mode 100644 index 0000000000000000000000000000000000000000..6a1469e4a5d4d7fdf60c6125989f3da107755123 Binary files /dev/null and b/_screenshots/templates/oneliner-cpu_usage.html.png differ diff --git a/_screenshots/templates/oneliner.html.png b/_screenshots/templates/oneliner.html.png new file mode 100644 index 0000000000000000000000000000000000000000..37b2b06898083a3c90a7017b079eea700a3bfefe Binary files /dev/null and b/_screenshots/templates/oneliner.html.png differ diff --git a/app.py b/app.py index c5eff58bf897466c21269a0efaebde4ed3ea8dec..569681a22ea61534262d3957347140dcbcfbbc42 100644 --- a/app.py +++ b/app.py @@ -1,119 +1,100 @@ import requests -from flask import Flask, request, make_response, render_template - -from config import url, hookshot_params, flask_params, msg_templates, msg_template_default -from validate import grafana_validate_incoming +from flask import Flask, make_response, render_template, request +from config import ( + flask_params, + hookshot_params, + msg_template_default, + msg_templates, + url, +) +from sanitize import grafana_sanitize_incoming app = Flask(__name__, **flask_params) +app.jinja_env.lstrip_blocks = True +app.jinja_env.trim_blocks = True -@app.route("/webhook/slack/<hook>", methods=['POST']) +@app.route("/webhook/slack/<hook>", methods=["POST"]) def slack(hook): - plain = '' - html = '' + plain = "" + html = "" incoming = request.json - print('Got incoming /slack hook: ' + str(incoming)) + print("Got incoming /slack hook: " + str(incoming)) - attachments = incoming.get('attachments', []) - username = str(incoming.get('username', '')) + attachments = incoming.get("attachments", []) + username = str(incoming.get("username", "")) for attachment in attachments: - color = str(attachment.get('color', '')).lower() - title = str(attachment.get('title', '')) - title_link = str(attachment.get('title_link', '')) - text = str(attachment.get('text', '')) - footer = str(attachment.get('footer', '')) - fields = attachment.get('fields', []) + color = str(attachment.get("color", "")).lower() + title = str(attachment.get("title", "")) + title_link = str(attachment.get("title_link", "")) + text = str(attachment.get("text", "")) + footer = str(attachment.get("footer", "")) + fields = attachment.get("fields", []) - html += '<font color="' + color + '">' if color else '' + html += '<font color="' + color + '">' if color else "" if title and title_link: - plain += title + ' ' + title_link + '\n' - html += '<b><a href="' + title_link + '">' + title + '</a></b><br/>\n' + plain += title + " " + title_link + "\n" + html += '<b><a href="' + title_link + '">' + title + "</a></b><br/>\n" elif title: - plain += title + '\n' - html += '<b>' + title + '</b><br/>\n' + plain += title + "\n" + html += "<b>" + title + "</b><br/>\n" if text: - plain += text + '\n' - html += text + '<br/>\n' + plain += text + "\n" + html += text + "<br/>\n" for field in fields: - title = str(field.get('title', '')) - value = str(field.get('value', '')) + title = str(field.get("title", "")) + value = str(field.get("value", "")) if title and value: - plain += title + ': ' + value + '\n' - html += '<b>' + title + '</b>: ' + value + '<br/>\n' + plain += title + ": " + value + "\n" + html += "<b>" + title + "</b>: " + value + "<br/>\n" if footer: - plain += footer + '\n' - html += footer + '<br/>\n' + plain += footer + "\n" + html += footer + "<br/>\n" - html += '</font>' if color else '' + html += "</font>" if color else "" if plain and html: if username: - json = {'text':plain,'html':html,'username':username} + json = {"text": plain, "html": html, "username": username} else: - json = {'text':plain,'html':html} - print('Sending hookshot: ' + str(json)) + json = {"text": plain, "html": html} + print("Sending hookshot: " + str(json)) r = requests.post(url + hook, json=json) else: - print('Invalid format, sending unmodified.') + print("Invalid format, sending unmodified.") r = requests.post(url + hook, json=incoming) - response = make_response('ok', 200) + response = make_response("ok", 200) response.mimetype = "text/plain" return response -@app.route("/webhook/grafana/<hook>", methods=['POST', 'PUT']) -def grafana(hook): - """ - see https://grafana.com/docs/grafana/latest/alerting/alerting-rules/manage-contact-points/integrations/webhook-notifier/ - todo: - - handle query params to select different templates - - handle empty -> default - """ +@app.route("/webhook/grafana/<hook>", methods=["POST", "PUT"]) +def grafana(hook): args = request.args - if "template" in args.keys(): - template_type = args.get("template") - if template_type not in msg_templates: - template_type = msg_template_default - else: + + template_type = args.get("template", msg_template_default) + if template_type not in msg_templates: template_type = msg_template_default - if "version" in args.keys(): - version = args.get("version") - else: - version = "v2" - - print(args) - print(f"template: {template_type}, version: {version}") + version = args.get("version", "v2") # unused at the moment incoming = request.json - print('Got incoming /grafana hook: ' + str(incoming)) + print("Got incoming /grafana hook: " + str(incoming)) - if not isinstance(incoming, dict): - incoming = dict() - - grafana_validate_incoming(incoming) - - t = lambda fmt: f"{template_type}.{fmt}.jinja" - plain = render_template(t("txt"), **incoming) - html = render_template(t("html"), **incoming) - - if plain and html: - json = {'text':plain,'html':html, **hookshot_params} - print('Sending hookshot: ' + str(json)) - r = requests.post(url + hook, json=json) - else: - print('Invalid format, sending incoming as str.') - r = requests.post(url + hook, json={'text':'Invalid format: ' + str(incoming)}) + sanitized = grafana_sanitize_incoming(incoming) - return {"ok":True} + plain = render_template(f"{template_type}.txt.jinja", **sanitized) + html = render_template(f"{template_type}.html.jinja", **sanitized) + json = {"text": plain, "html": html, **hookshot_params} + print("Sending hookshot: " + str(json)) + r = requests.post(url + hook, json=json) -if __name__ == "__main__": - app.run(host="0.0.0.0", port=9080, debug=True) + return {"ok": True} diff --git a/config.py b/config.py index 000562e4b7a57913ebfd65c3e1d87fc397aadb80..525292e4fff501423607f212307b2ad3be56e1ec 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,44 @@ -url = 'https://hookshot.mbot.ethz.ch/webhook/' +url = "https://hookshot.mbot.ethz.ch/webhook/" flask_params = {"template_folder": "template"} hookshot_params = {"version": "v2", "msgtype": "m.notice"} -msg_templates = ("default", "concise", "ultra_concise") -msg_template_default = "ultra_concise" + +reserved_label_keys = ["grafana_folder", "alertname"] +special_annotation_keys = ["description", "summary"] + +reserved_labels = {k: "" for k in reserved_label_keys} +special_annotations = {k: "" for k in special_annotation_keys} + +incoming_default = { + "receiver": "", + "status": "", + "orgId": 0, + "alerts": [], + "groupLabels": {}, + "commonLabels": {}, + "commonAnnotatons": {}, + "externalUrl": "", + "version": "", + "groupKey": "", + "commonStartsAtParsed": "", # computed +} + +alert_default = { + "status": "", + "labels": {}, + "annotations": {}, + "startsAt": "", + "endsAt": "", + "values": {}, + "generatorURL": "", + "fingerprint": "", + "silenceURL": "", + "imageURL": "", + "uniqueLabels": "", # computed + "uniqueAnnotations": "", # computed + "startsAtParsed": "", # computed + "endsAtParsed": "", # computed +} + +msg_templates = ("default", "oneliner", "detailed", "detailed_table") +msg_template_default = "default" diff --git a/sanitize.py b/sanitize.py new file mode 100644 index 0000000000000000000000000000000000000000..11abfafee310543f8b06b6ee01a306af68233490 --- /dev/null +++ b/sanitize.py @@ -0,0 +1,99 @@ +from datetime import datetime +from typing import Dict, List, Tuple + +from config import alert_default, incoming_default, reserved_labels, special_annotations + + +def get_unique_dict(source: Dict, common: Tuple[str]) -> Dict: + """Filters out entries present in common keys""" + return {k: v for k, v in source.items() if k not in common} + + +def get_common_time(alerts: List[Dict], key: str) -> str | None: + """Return formatted timestamp if is common across alert instances""" + unique_starts_at = {a[key] for a in alerts} + if len(unique_starts_at) == 1: + return unique_starts_at.pop() + + +def merge_with_default_dict(source_raw: Dict, default: Dict) -> Dict: + """Merges source dict with default dict. Handles source_raw being None""" + source = source_raw if isinstance(source_raw, dict) else dict() + return default | source + + +def grafana_sanitize_incoming(incoming: Dict) -> Dict: + sanitized = merge_with_default_dict(incoming, incoming_default) + + sanitized["commonLabels"] = merge_with_default_dict( + sanitized["commonLabels"], reserved_labels + ) + + sanitized["commonAnnotations"] = merge_with_default_dict( + sanitized["commonAnnotations"], special_annotations + ) + + sanitized["alerts"] = [ + grafana_sanitize_alert( + alert, + sanitized["commonLabels"].keys(), + sanitized["commonAnnotations"].keys(), + ) + for alert in incoming.get("alerts", []) + ] + + firing_alerts = [a for a in sanitized["alerts"] if a["status"] == "firing"] + sanitized["numberOfFiring"] = len(firing_alerts) + + for special_key in special_annotations.keys(): + sanitized[special_key] = sanitized["commonAnnotations"].get(special_key, "") + + sanitized["normalAnnotations"] = { + k: v + for k, v in sanitized["commonAnnotations"].items() + if k not in special_annotations + } + sanitized["normalLabels"] = { + k: v for k, v in sanitized["commonLabels"].items() if k not in reserved_labels + } + + sanitized["commonStartsAtParsed"] = get_common_time( + sanitized["alerts"], "startsAtParsed" + ) + sanitized["commonEndsAtParsed"] = get_common_time( + sanitized["alerts"], "endsAtParsed" + ) + + return sanitized + + +def grafana_sanitize_alert( + alert: Dict, common_label_keys: Tuple[str], common_annotation_keys: Tuple[str] +) -> Dict: + sanitized = alert_default | alert + + sanitized["values"] = merge_with_default_dict(sanitized["values"], dict()) + + sanitized["uniqueLabels"] = get_unique_dict(sanitized["labels"], common_label_keys) + sanitized["uniqueAnnotations"] = get_unique_dict( + sanitized["annotations"], common_annotation_keys + ) + + sanitized["startsAtParsed"] = format_dt(sanitized.get("startsAt", "")) + sanitized["endsAtParsed"] = format_dt(sanitized.get("endsAt", "")) + + return sanitized + + +def format_dt(dt_str: str) -> str: + """ + Returns empty string if error or is year 1 e.g. zero value of golang time.Time + see https://pkg.go.dev/time#Time + """ + try: + dt = datetime.fromisoformat(dt_str) + if dt.year < 2: + return "" + return dt.strftime("%Y-%m-%d %H:%M:%S %z") + except ValueError: + return "" diff --git a/template/concise.html.jinja b/template/concise.html.jinja deleted file mode 100644 index 3c423ed8ebf967ef599cdf65846f59ae51b5beef..0000000000000000000000000000000000000000 --- a/template/concise.html.jinja +++ /dev/null @@ -1,16 +0,0 @@ -{% if alerts | length %} -{% for alert in alerts %} -<b><a href="{{ alert.generatorURL }}">[{{ alert.status }}] {{ alert.labels.alertname }}</a>:</b> -{% if alert.labels %} -{% for key, value in alert.labels.items() %} -{{ key }}={{ value }} {{ ", " if not loop.last else " " }} -{% endfor %} -{% endif %} -(<a href="{{ alert.silenceURL }}">Silence</a>)<br> -{% if alert.valuesForJinja %} -{% for key, value in alert.valuesForJinja.items() %} -<b>{{ key }}:</b> {{ value }}<br> -{% endfor %} -{% endif %} -{% endfor %} -{% endif %} diff --git a/template/concise.txt.jinja b/template/concise.txt.jinja deleted file mode 100644 index 1451d1460cd1d05a12e29b13ceae29f2b5a3917a..0000000000000000000000000000000000000000 --- a/template/concise.txt.jinja +++ /dev/null @@ -1,8 +0,0 @@ -{% if alerts | length %}{% for alert in alerts %} -[{{ alert.status }}] {{ alert.labels.alertname }}: -Alert: {{ alert.generatorURL }} -Silence: {{ alert.silenceURL }} -{% if alert.labels %}{% for key, value in alert.labels.items() %}{{ key }}={{ value }}, {% endfor %}{% endif %} -{% if alert.valuesForJinja %}{% for key, value in alert.valuesForJinja.items() %}{{ key }}: {{ value }}, {% endfor %}{% endif %} -___ -{% endfor %}{% endif %} diff --git a/template/default.html.jinja b/template/default.html.jinja index 0d2fe3945c74680df5479d9fb5029717fc1edeb2..407e50003bdea3e8393a11abd587e6e0b9be4d2a 100644 --- a/template/default.html.jinja +++ b/template/default.html.jinja @@ -1,34 +1,14 @@ -<b>{{ status | upper }}:{{ noFiring }} ({{ commonLabels.values() | join(" ") }})</b> -<p><a href="{{ externalUrl }}?orgId={{ orgId }}">Grafana Instance</a></p> -{% if alerts | length %}{% for alert in alerts %} -<p><b>{{ alert.labels.alertname }}</b> (Status: {{ alert.status }}): - <a href="{{ alert.generatorURL }}">View</a>, <a href="{{ alert.silenceURL }}">Silence</a> -</p> - -{% if alert.valuesForJinja %} -<b>Values</b> -<table> - {% for key, value in alert.valuesForJinja.items() %} - <tr> - <td>{{ key }}</td> - <td>{{ value }}</td> - </tr> - {% endfor %} -</table> -{% endif %} - -{% if alert.labels %} -<b>Labels</b> -<table> - {% for key, value in alert.labels.items() %} - <tr> - <td>{{ key }}</td> - <td>{{ value }}</td> - </tr> - {% endfor %} -</table> -{% endif %} -<p><i>(StartsAt: {{alert.startsAtParsed }}{% if alert.endsAtParsed %}, EndsAt: {{alert.endsAtParsed }}{% endif %})</i> -</p> -<br> -{% endfor %}{% endif %} \ No newline at end of file +{%- if alerts | length %}{% for alert in alerts -%} +<b><a href="{{ alert['generatorURL'] }}">[{{ alert['status'] }}] {{ alert['labels']['alertname'] }}</a></b> +{%- for key, value in alert['uniqueLabels'].items() -%} +{%- if key != 'alertname' %} +{{- " " + key }}={{ value }} {{ "," if not loop.last }} +{%- endif %} +{%- endfor -%} + (<a href="{{ alert['silenceURL'] }}">Silence</a>)<br> +{%- if alert['values'] %} +{%- for key, value in alert['values'].items() -%} +<b>{{ key }}:</b> {{ value }}<br> +{%- endfor %} +{%- endif %} +{%- endfor %}{% endif %} \ No newline at end of file diff --git a/template/default.txt.jinja b/template/default.txt.jinja index c5ae8c593278be08284f2ef76d33ba5698e7be5d..26b827c4d6fa15deb77614a586b4673155af1110 100644 --- a/template/default.txt.jinja +++ b/template/default.txt.jinja @@ -1,26 +1,10 @@ -{{ status | upper }}:{{ noFiring }} ({{ commonLabels.values() | join(" ") }}) -Grafana: {{ externalUrl }}?orgId={{ orgId }} - -{% if alerts | length %} -{% for alert in alerts %} ---- -Name: {{ alert.labels.alertname }} -Status: {{ alert.status }} -View alert: {{ alert.generatorURL }} -Silence alert: {{ alert.silenceURL }} - -{% if alert.Values %} -Values: -{% for key, value in alert.Values.items() %} -{{ key }}: {{ value }} -{% endfor %} -{% endif %} - -{% if alert.labels %} -Labels -{% for key, value in alert.labels.items() %} -{{ key }}: {{ value }} -{% endfor %} -{% endif %} -{% endfor %} -{% endif %} +{%- for alert in alerts -%} +[{{ alert['status'] }}] {{ alert['labels']['alertname'] -}} +{%- for key, value in alert['uniqueLabels'].items() %} +{{- " " + key }}={{value}} +{%- endfor %}: +{%- for key, value in alert['values'].items() %} +{{- "\n" + key }} = {{ value }} +{%- endfor -%} +{{- "\n" if not loop.last }} +{%- endfor %} \ No newline at end of file diff --git a/template/detailed.html.jinja b/template/detailed.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..80df0e2bf5f1feb61cc9f1ba85d7ba2dd74c392a --- /dev/null +++ b/template/detailed.html.jinja @@ -0,0 +1,83 @@ +{% for alert in alerts -%} +<p><b><a href="{{ alert['generatorURL'] }}">[{{ alert['status'] }}] {{ alert['labels']['alertname'] }}</a></b> (<a href="{{ alert['silenceURL'] }}">Silence</a>): +{%- if alert['imageURL'] -%} +, <a href="{{ alert['imageURL'] }}">Image</a> +{%- endif -%} + +<br> +{%- for key, value in alert['values'].items() -%} +{{- "<br>" if not loop.first }} +<b>{{ key }}:</b> {{ value }} +{%- endfor -%} + +<i> + +{%- if alert['uniqueLabels'] -%} +<br>Labels: +{{- "<br>" if (alert['uniqueLabels'] | length) > 1 else " " }} +{%- for key, value in alert['uniqueLabels'].items() %} +{{- "<br>" if not loop.first }} +{{- " " if (alert['uniqueLabels'] | length) > 1 }} +{{ key }} = {{ value }}<br> +{%- endfor %}{% endif %} + +{%- if alert['uniqueAnnotations'] -%} +<br>Annotations: +{{- "<br>" if (alert['uniqueAnnotations'] | length) > 1 else " " }} +{%- for key, value in alert['uniqueAnnotations'].items() %} +{{- "<br>" if not loop.first }} +{{- " " if (alert['uniqueAnnotations'] | length) > 1 }} +{{- key }} = {{ value }}<br> +{%- endfor %}{% endif %} + +{%- if not commonStartsAtParsed %} +<i>(StartsAt: {{alert['startsAtParsed'] }}{% if alert['endsAtParsed'] %}, EndsAt: {{alert['endsAtParsed'] }}{% endif %})</i><br> +{% endif -%} + +</i></p>{% endfor %} + +<p><b>Metadata:</b><br> +<b>Grafana Folder:</b> {{ commonLabels['grafana_folder'] }}<br> +{%- if summary -%} +<b>Summary:</b> {{ summary }}<br>{% endif %} + +{%- if description -%} +<b>Description:</b> {{ description }}<br>{% endif %} + +{%- if groupLabels -%} +<b>Grouped By:</b> +{{- "<br>" if (groupLabels | length) > 1 else " " }} +{%- for key, value in groupLabels.items() %} +{{- " " if (groupLabels | length) > 1 }} +{{- key }} = {{ value }}<br> +{%- endfor %}{% endif %} + +{%- if normalLabels -%} +<b>Common Labels:</b> +{{- "<br>" if (normalLabels | length) > 1 else " " }} +{%- for key, value in normalLabels.items() %} +{{- " " if (normalLabels | length) > 1 }} +{{- key }} = {{ value }}<br> +{%- endfor %}{% endif %} + +{%- if normalAnnotations -%} +<b>Common Annotations:</b> +{{- "<br>" if (normalAnnotations | length) > 1 else " " }} +{%- for key, value in normalAnnotations.items() %} +{{- " " if (normalAnnotations | length) > 1 }} +{{- key }} = {{ value }}<br> +{%- endfor %}{% endif %} + +{%- if commonStartsAtParsed -%} +<b>Starts At:</b> {{ commonStartsAtParsed }} +{%- endif -%} +{%- if commonEndsAtParsed -%} +<br><b>Ends At:</b> {{ commonEndsAtParsed }} +{%- endif -%} +</p> + + + +{%- if truncatedAlerts %} +<p><b>Truncated Alerts:</b> {{ truncatedAlerts }}</p> +{% endif %} diff --git a/template/detailed.txt.jinja b/template/detailed.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..d733e9a61ec41329a47481209d08e7d48d93e8a4 --- /dev/null +++ b/template/detailed.txt.jinja @@ -0,0 +1,67 @@ +{% for alert in alerts %} +[{{ alert['status'] }}] {{ alert['labels']['alertname'] }} +{% for key, value in alert['values'].items() %} +{{ key }}: {{ value }} +{% endfor %} + +{%- if alert['uniqueLabels'] %} +Labels: +{%- for key, value in alert['uniqueLabels'].items() %} +{{- "," if not loop.first }} +{{- " " + key }} = {{ value }} +{% endfor %}{% endif %} + +{% if alert['uniqueAnnotations'] %} +Annotations: +{% for key, value in alert['uniqueAnnotations'].items() %} +{{ key }} = {{ value }} +{% endfor %}{% endif %} + +{%- if not commonStartsAtParsed %} +StartsAt: {{alert['startsAtParsed'] }}{% if alert['endsAtParsed'] %}, EndsAt: {{alert['endsAtParsed'] }} +{% endif %} +{% endif %}{% endfor -%} + +Metadata: +Grafana Folder: {{ commonLabels['grafana_folder'] }} +{% if summary %} +Summary: {{ summary }} +{% endif -%} + +{% if description %} +Description: {{ description }} +{% endif -%} + +{% if groupLabels %} +Grouped By: +{{- "\n" if (groupLabels | length) > 1 else " " }} +{%- for key, value in groupLabels.items() %} +{{- key }} = {{ value }} +{% endfor %} +{% endif %} + +{%- if normalLabels %} +Common Labels: +{{- "\n" if (normalLabels | length) > 1 else " " }} +{%- for key, value in normalLabels.items() %} +{{- key }} = {{ value }} +{%- endfor %} +{% endif %} + +{%- if normalAnnotations %} +Common Annotations: +{% for key, value in normalAnnotations.items() %} +{{ key }} = {{ value }} +{% endfor %} +{% endif %} + +{%- if commonStartsAtParsed -%} +Starts At: {{ commonStartsAtParsed -}} +{% endif -%} +{% if commonEndsAtParsed -%} +Ends At: {{ commonEndsAtParsed -}} +{%- endif -%} + +{%- if truncatedAlerts %} +Truncated Alerts: {{ truncatedAlerts }} +{% endif -%} \ No newline at end of file diff --git a/template/detailed_table.html.jinja b/template/detailed_table.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..269c5b2b25654e33422dacc66c65aee94240a201 --- /dev/null +++ b/template/detailed_table.html.jinja @@ -0,0 +1,88 @@ +{% for alert in alerts -%} +<p><b><a href="{{ alert['generatorURL'] }}">[{{ status}}] {{ alert['labels']['alertname'] }}</a></b>(<a href="{{ alert['silenceURL'] }}">Silence</a>): + +{% if alert['values'] %} +<table> +<caption>Values</caption> +{% for key, value in alert['values'].items() %} +<tr> +<th>{{ key }}</th> +<td>{{ value }}</td> +</tr> +{% endfor %} +</table> +{% else %} +<br> +{% endif %} + +{% if alert['uniqueLabels'] %} +<table> +<caption>Labels</caption> +{% for key, value in alert['uniqueLabels'].items() %} +<tr> +<td>{{ key }}</td> +<td>{{ value }}</td> +</tr> +{% endfor %} +</table> +{% endif %} + +{% if alert['uniqueAnnotations'] %} +<b>Annotations</b> +<table> + {% for key, value in alert['uniqueAnnotations'].items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} + +<i>(StartsAt: {{alert['startsAtParsed'] }}{% if alert['endsAtParsed'] %}, EndsAt: {{alert['endsAtParsed'] }}{% endif %})</i> +</p> +{% endfor %} + +<p><b>Metadata:</b></p> + +<table> +<tr><td>Grafana Folder</td><td>{{ commonLabels['grafana_folder'] }}</td></tr> +{%- if summary -%}<tr><td>Summary</td><td>{{ summary }}</td></tr>{% endif %} +{%- if description -%}<tr><td>Description</td><td>{{ description }}</td>{% endif %} +</table> + +{% if groupLabels %} +<table> + <caption>Grouped By:</caption> + {% for key, value in groupLabels.items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} + +{% if normalLabels %} +<table> + <caption>Common Labels</caption> + {% for key, value in normalLabels.items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} + +{% if normalAnnotations %} +<table> + <caption>Common Annotations</caption> + {% for key, value in normalAnnotations.items() %} + <tr> + <td>{{ key }}</td> + <td>{{ value }}</td> + </tr> + {% endfor %} +</table> +{% endif %} \ No newline at end of file diff --git a/template/detailed_table.txt.jinja b/template/detailed_table.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..b5a0f3af59c221954ecc49b8b314d222219ffaf1 --- /dev/null +++ b/template/detailed_table.txt.jinja @@ -0,0 +1 @@ +{% include 'detailed.txt.jinja' %} \ No newline at end of file diff --git a/template/oneliner.html.jinja b/template/oneliner.html.jinja new file mode 100644 index 0000000000000000000000000000000000000000..fb0d5c7491cb586d36e5333d154c53994e54838a --- /dev/null +++ b/template/oneliner.html.jinja @@ -0,0 +1,12 @@ +{% if alerts | length %}{% for alert in alerts -%} +<b><a href="{{ alert['generatorURL'] }}">[{{ alert.status }}] +{{- " " + alert['labels']['alertname'] }}</a></b> +{%- for key, value in alert['uniqueLabels'].items() %} +{{- " " + key }}={{value}} +{%- endfor -%} + (<a href="{{ alert['silenceURL'] }}">Silence</a>)  +{%- for key, value in alert['values'].items() %} +{{- ", " if not loop.first -}} +<b>{{ key }}:</b> {{ value }} +{%- endfor %}<br> +{%- endfor -%}{% endif -%} diff --git a/template/oneliner.txt.jinja b/template/oneliner.txt.jinja new file mode 100644 index 0000000000000000000000000000000000000000..e3f4e5a7a8b58f3734ce19f0883cf5c201b8ce2f --- /dev/null +++ b/template/oneliner.txt.jinja @@ -0,0 +1,11 @@ +{%- for alert in alerts -%} +[{{ alert['status'] }}] {{ alert['labels']['alertname'] -}} +{%- for key, value in alert['uniqueLabels'].items() %} +{{- " " + key }}={{value}} +{%- endfor %}: +{%- for key, value in alert['values'].items() %} +{{- "," if not loop.first }} +{{- " " + key }} = {{ value }} +{%- endfor -%} +{{- "\n" if not loop.last }} +{%- endfor %} \ No newline at end of file diff --git a/template/slack.html.jinja b/template/slack.html.jinja index bfe10b05cb95ba0de489a1ef12d7e64cf06d003f..dc085a614aa8b0ffcb1283ccabd48267f9c34c0b 100644 --- a/template/slack.html.jinja +++ b/template/slack.html.jinja @@ -13,7 +13,7 @@ {% endif %} {% for field in fields %} -<b>{{ field.title }}:</b> {{ value}}<br/> +<b>{{ field['title'] }}:</b> {{ value}}<br/> {% endfor %} {% if footer %} diff --git a/template/slack.txt.jinja b/template/slack.txt.jinja index 93a2b4bf37a96c5ff03cdf91312f8e81dab6d61f..cbb9fa26613cc533e52a24be4b2d4268a62f7ad9 100644 --- a/template/slack.txt.jinja +++ b/template/slack.txt.jinja @@ -5,7 +5,7 @@ {% if text %}{{ text }}{% endif %} {% for field in fields %} -{{ field.title }}: {{ value}} +{{ field['title'] }}: {{ value}} {% endfor %} {{ footer}} diff --git a/template/ultra_concise.html.jinja b/template/ultra_concise.html.jinja deleted file mode 100644 index fa2d51d5a798dabc6432d9248a2fbe65812f556d..0000000000000000000000000000000000000000 --- a/template/ultra_concise.html.jinja +++ /dev/null @@ -1,18 +0,0 @@ -{% if alerts | length %} -{% for alert in alerts %} -{{ "<br>" if not loop.first }} -<b><a href="{{ alert.generatorURL }}">[{{ alert.status }}] {{ alert.labels.alertname }}</a></b> -(<a href="{{ alert.silenceURL }}">Silence</a>)<b>:</b> -{% if alert.labels %} -{% for key, value in alert.labels.items() %} -{{ key }}={{ value }} {{- ", " if not loop.last else " " }} -{% endfor %} -{% endif %} -<br> -{% if alert.valuesForJinja %} -{% for key, value in alert.valuesForJinja.items() %} -<b>{{ key }}:</b> {{ value }}{{ ", " if not loop.last else " " }} -{% endfor %} -{% endif %} -{% endfor %} -{% endif %} diff --git a/template/ultra_concise.txt.jinja b/template/ultra_concise.txt.jinja deleted file mode 100644 index 1451d1460cd1d05a12e29b13ceae29f2b5a3917a..0000000000000000000000000000000000000000 --- a/template/ultra_concise.txt.jinja +++ /dev/null @@ -1,8 +0,0 @@ -{% if alerts | length %}{% for alert in alerts %} -[{{ alert.status }}] {{ alert.labels.alertname }}: -Alert: {{ alert.generatorURL }} -Silence: {{ alert.silenceURL }} -{% if alert.labels %}{% for key, value in alert.labels.items() %}{{ key }}={{ value }}, {% endfor %}{% endif %} -{% if alert.valuesForJinja %}{% for key, value in alert.valuesForJinja.items() %}{{ key }}: {{ value }}, {% endfor %}{% endif %} -___ -{% endfor %}{% endif %} diff --git a/templates.md b/templates.md new file mode 100644 index 0000000000000000000000000000000000000000..a41f3bbd545770f5aa51d7fdd7f7cf3c59801bf4 --- /dev/null +++ b/templates.md @@ -0,0 +1,186 @@ +# Templates + +## `default` + + + +```txt +[firing] humidity: +humidity_latest = 51.479 +``` + +```txt +[firing] temp: +temp_latest = 26.262 +``` + +```txt +[firing] wetbulb: +H = 52.158 +T = 26.456 +WB = 25.023772144 +``` + +<details><summary>cpu_usage</summary> + + + +```txt +[firing] cpu_usage cpu=0: +B = 2.4578 +[firing] cpu_usage cpu=1: +B = 2.4058 +[firing] cpu_usage cpu=2: +B = 2.48043 +[firing] cpu_usage cpu=3: +B = 2.41827 +[firing] cpu_usage cpu=4: +B = 2.2762 +[firing] cpu_usage cpu=5: +B = 9.42877 +[firing] cpu_usage cpu=6: +B = 2.36813 +[firing] cpu_usage cpu=7: +B = 2.40608 +``` + +</details> + +## `oneliner` + + + +```txt +[firing] humidity: humidity_latest = 50.015 +``` + +```txt +[firing] temp: temp_latest = 26.198 +``` + +```txt +[firing] wetbulb: H = 50.045, T = 26.155, WB = 24.478525925 +``` + +<details><summary>cpu_usage</summary> + + + +```txt +[firing] cpu_usage cpu=0: B = 2.46086 +[firing] cpu_usage cpu=1: B = 2.40885 +[firing] cpu_usage cpu=2: B = 2.48347 +[firing] cpu_usage cpu=3: B = 2.4212 +[firing] cpu_usage cpu=4: B = 2.27917 +[firing] cpu_usage cpu=5: B = 9.42941 +[firing] cpu_usage cpu=6: B = 2.37108 +[firing] cpu_usage cpu=7: B = 2.40908 +``` + +</details> + +## `detailed` + + + +```txt +[firing] humidity +humidity_latest: 52.097 + +Metadata: +Grafana Folder: capybara +Grouped By: grafana_folder = capybara +Common Labels: test_label = testCommon Annotations: +test_annotation = test +Starts At: 2024-05-22 11:08:10 +0200 +``` + +```txt +[firing] temp +temp_latest: 26.155 + +Metadata: +Grafana Folder: capybara +Grouped By: grafana_folder = capybara +Starts At: 2024-05-10 14:42:10 +0200 +``` + +```txt +[firing] wetbulb +H: 52.097 +T: 26.155 +WB: 24.758552105000003 + +Metadata: +Grafana Folder: capybara +Summary: calculates the approximated wet bulb temperature +Description: https://schweizer-fn.de/lueftung/feuchte/feuchte.php +Grouped By: grafana_folder = capybara +Starts At: 2024-05-15 17:52:10 +0200 +``` + +<details><summary>cpu_usage</summary> + + + +```txt +[firing] cpu_usage +B: 2.46297 +Labels: cpu = 0 + +[firing] cpu_usage +B: 2.41088 +Labels: cpu = 1 + +[firing] cpu_usage +B: 2.48554 +Labels: cpu = 2 + +[firing] cpu_usage +B: 2.42328 +Labels: cpu = 3 + +[firing] cpu_usage +B: 2.28143 +Labels: cpu = 4 + +[firing] cpu_usage +B: 9.42974 +Labels: cpu = 5 + +[firing] cpu_usage +B: 2.37328 +Labels: cpu = 6 + +[firing] cpu_usage +B: 2.41123 +Labels: cpu = 7 + +Metadata: +Grafana Folder: capybara +Summary: CPU usage by core +Description: Does what it says on the tin +Grouped By: grafana_folder = capybara +Common Annotations: +customLabel = Test Test Test +runbook_url = https://web.archive.org/ +Starts At: 2024-05-25 06:25:10 +0200 +``` + +</details> + +## `detailed_table` + +Plaintext version is the same as `detailed`. + + + + + +<details><summary>cpu_usage</summary> + + +(...) + + +</details> diff --git a/validate.py b/validate.py deleted file mode 100644 index c7fe8f2822a08187ddcbd5f7e0ff52dd0493f109..0000000000000000000000000000000000000000 --- a/validate.py +++ /dev/null @@ -1,85 +0,0 @@ -from datetime import datetime -from typing import Dict - -# https://grafana.com/docs/grafana-cloud/alerting-and-irm/alerting/configure-notifications/manage-contact-points/integrations/webhook-notifier/ - - -def validate_str_entry(d: Dict, k: str): - """Empty string if not exists""" - d[k] = d.get(k, "") - - -def validate_number_entry(d: Dict, k: str): - """0 if not exists""" - d[k] = d.get(k, 0) - - -def validate_dict_entry(d: Dict, k: str): - """Empty dict if not exists""" - entry = d.get(k, None) - if not isinstance(entry, dict): - entry = dict() - d[k] = entry - - -def grafana_validate_incoming(incoming: Dict): - """ - Makes sure the necessary keys are present in the incoming dict so it can be parsed by jinja - """ - string_keys = ("receiver", "status", "externalUrl", "version", "groupKey") - for key in string_keys: - validate_str_entry(incoming, key) - - validate_number_entry(incoming, "orgId") - validate_number_entry(incoming, "truncatedAlerts") - - validate_dict_entry(incoming, "groupLabels") - validate_dict_entry(incoming, "commonLabels") - validate_dict_entry(incoming, "commonAnnotations") - - incoming["alerts"] = alerts = incoming.get("alerts", []) - - for alert in alerts: # Handle non dict values? - grafana_validate_alert(alert) - - l = lambda a: a.get("status", "") == "firing" - incoming["no_firing"] = len(tuple(filter(l, alerts))) - - -def grafana_validate_alert(alert: Dict): - - string_keys = ( - "status", - "startsAt", - "endsAt", - "generatorURL", - "fingerprint", - "silenceURL", - "imageURL", - ) - for key in string_keys: - validate_str_entry(alert, key) - - validate_dict_entry(alert, "labels") - validate_dict_entry(alert, "annotations") - validate_dict_entry(alert, "values") - - # fixes "values" being shadowed in jinja - alert["valuesForJinja"] = alert.get("values", None) - - alert["startsAtParsed"] = format_dt(alert.get("startsAt", "")) - alert["endsAtParsed"] = format_dt(alert.get("endsAt", "")) - - -def format_dt(dt_str: str) -> str: - """ - Returns empty string if error or is year 1 e.g. zero value of golang time.Time - see https://pkg.go.dev/time#Time - """ - try: - dt = datetime.fromisoformat(dt_str) - if dt.year < 2: - return "" - return dt.strftime("%Y-%m-%d %H:%M:%S %z") - except ValueError: - return ""