commit 905ed76e66d4ecb56bfc5af61807890ba9a80296 Author: rhino Date: Fri Apr 10 21:47:24 2026 +0000 Upload files to "/" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9c9ef64 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py . + +CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:5000", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..6a4f8c4 --- /dev/null +++ b/app.py @@ -0,0 +1,98 @@ +import os +import json +import requests +from flask import Flask, request, jsonify + +app = Flask(__name__) + +ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY") +NTFY_URL = os.environ.get("NTFY_URL", "https://ntfy.aery.tech/alerts") +NTFY_TOKEN = os.environ.get("NTFY_TOKEN", "") + + +def ask_claude(alert_name, alert_state, labels, annotations, values): + prompt = f"""You are a homelab infrastructure assistant. Analyze this Grafana alert and provide a concise, plain-English summary with suggested actions. + +Alert Name: {alert_name} +State: {alert_state} +Labels: {json.dumps(labels, indent=2)} +Annotations: {json.dumps(annotations, indent=2)} +Values: {json.dumps(values, indent=2)} + +Respond in 2-3 sentences max. Be direct and actionable. Focus on what happened and what to check.""" + + response = requests.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + json={ + "model": "claude-haiku-4-5-20251001", + "max_tokens": 256, + "messages": [{"role": "user", "content": prompt}], + }, + timeout=15, + ) + response.raise_for_status() + return response.json()["content"][0]["text"] + + +def send_ntfy(title, message, priority="default", tags=None): + headers = { + "Title": title, + "Priority": priority, + "Content-Type": "text/plain", + } + if tags: + headers["Tags"] = ",".join(tags) + if NTFY_TOKEN: + headers["Authorization"] = f"Bearer {NTFY_TOKEN}" + + requests.post(NTFY_URL, data=message.encode("utf-8"), headers=headers, timeout=10) + + +@app.route("/webhook", methods=["POST"]) +def webhook(): + data = request.get_json(silent=True) + if not data: + return jsonify({"error": "Invalid JSON"}), 400 + + alerts = data.get("alerts", []) + if not alerts: + return jsonify({"status": "no alerts"}), 200 + + for alert in alerts: + alert_name = alert.get("labels", {}).get("alertname", "Unknown Alert") + alert_state = alert.get("status", "unknown") + labels = alert.get("labels", {}) + annotations = alert.get("annotations", {}) + values = alert.get("values", {}) + + # Determine priority and tags based on state + if alert_state == "firing": + priority = "high" + tags = ["warning", "grafana"] + else: + priority = "default" + tags = ["white_check_mark", "grafana"] + + try: + summary = ask_claude(alert_name, alert_state, labels, annotations, values) + except Exception as e: + summary = f"Alert {alert_state}: {alert_name}. (Claude enrichment failed: {e})" + + title = f"🔔 {alert_name} [{alert_state.upper()}]" + send_ntfy(title, summary, priority=priority, tags=tags) + + return jsonify({"status": "ok"}), 200 + + +@app.route("/health", methods=["GET"]) +def health(): + return jsonify({"status": "ok"}), 200 + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..75c4f04 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + alert-enrichment: + build: . + container_name: alert-enrichment + restart: unless-stopped + ports: + - "5000:5000" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - NTFY_URL=${NTFY_URL} + - NTFY_TOKEN=${NTFY_TOKEN} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..57cae97 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask==3.1.0 +gunicorn==23.0.0 +requests==2.32.3