feat: add email controls and approval notifications
This commit is contained in:
+1
-1
@@ -3,4 +3,4 @@ WORKDIR /app
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
COPY main.py .
|
COPY main.py .
|
||||||
CMD ["python", "main.py"]
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8400"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [raven] %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s [raven] %(message)s")
|
||||||
@@ -29,6 +30,26 @@ MAX_HISTORY = 50
|
|||||||
SEVERITY_COLORS = {"critical": 15158332, "warning": 16776960, "info": 3066993}
|
SEVERITY_COLORS = {"critical": 15158332, "warning": 16776960, "info": 3066993}
|
||||||
|
|
||||||
|
|
||||||
|
def _state_path() -> Path:
|
||||||
|
return SITES_DIR / "raven" / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_state() -> dict:
|
||||||
|
p = _state_path()
|
||||||
|
if not p.exists():
|
||||||
|
return {"email_enabled": True}
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {"email_enabled": True}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict):
|
||||||
|
p = _state_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
p.write_text(json.dumps(state, indent=2))
|
||||||
|
|
||||||
|
|
||||||
class NotifyRequest(BaseModel):
|
class NotifyRequest(BaseModel):
|
||||||
message: str
|
message: str
|
||||||
severity: str = "info"
|
severity: str = "info"
|
||||||
@@ -78,6 +99,9 @@ def _send_discord(message: str, severity: str, source: str, timestamp: str):
|
|||||||
|
|
||||||
|
|
||||||
def _send_email(message: str, severity: str, source: str):
|
def _send_email(message: str, severity: str, source: str):
|
||||||
|
if not _load_state().get("email_enabled", True):
|
||||||
|
logger.info("email: disabled by toggle — skipping")
|
||||||
|
return
|
||||||
if not all([SMTP_USER, SMTP_PASSWORD, SMTP_TO]):
|
if not all([SMTP_USER, SMTP_PASSWORD, SMTP_TO]):
|
||||||
logger.warning("SMTP not configured — skipping email")
|
logger.warning("SMTP not configured — skipping email")
|
||||||
return
|
return
|
||||||
@@ -98,7 +122,7 @@ def _send_email(message: str, severity: str, source: str):
|
|||||||
logger.error(f"email send failed: {e}")
|
logger.error(f"email send failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _render_html(history: list, timestamp: str) -> str:
|
def _render_html(history: list, timestamp: str, email_enabled: bool = True) -> str:
|
||||||
rows = ""
|
rows = ""
|
||||||
for entry in reversed(history[-20:]):
|
for entry in reversed(history[-20:]):
|
||||||
sev = entry.get("severity", "info")
|
sev = entry.get("severity", "info")
|
||||||
@@ -114,40 +138,80 @@ def _render_html(history: list, timestamp: str) -> str:
|
|||||||
</tr>"""
|
</tr>"""
|
||||||
|
|
||||||
total = len(history)
|
total = len(history)
|
||||||
|
email_color = "#3fb950" if email_enabled else "#f85149"
|
||||||
|
email_label = "ENABLED" if email_enabled else "DISABLED"
|
||||||
|
btn_label = "Disable Email" if email_enabled else "Enable Email"
|
||||||
return f"""<!DOCTYPE html>
|
return f"""<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Raven — Notifications</title>
|
<title>Raven — Notifications</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
:root {{ --bg: #0d1117; --surface: #161b22; --border: #30363d; --dim: #21262d; --text: #e6edf3; --muted: #8b949e; }}
|
||||||
body {{ font-family: monospace; background: #0d1117; color: #c9d1d9; padding: 2rem; }}
|
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||||
h1 {{ color: #58a6ff; margin-bottom: 0.25rem; }}
|
body {{ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 15px; line-height: 1.6; }}
|
||||||
.desc {{ color: #8b949e; font-size: 0.9rem; margin: 0.4rem 0 0.25rem; }}
|
.site-nav {{ background: var(--surface); border-bottom: 1px solid var(--border); padding: 0.7rem 2rem; display: flex; align-items: center; gap: 0.6rem; position: sticky; top: 0; z-index: 10; }}
|
||||||
.meta {{ color: #8b949e; font-size: 0.85rem; margin-bottom: 2rem; }}
|
.nav-brand {{ color: #58a6ff; font-weight: 600; font-size: 0.9rem; text-decoration: none; }}
|
||||||
.meta a {{ color: #8b949e; }}
|
.nav-brand:hover {{ color: var(--text); }}
|
||||||
h2 {{ color: #8b949e; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; margin: 2rem 0 0.75rem; }}
|
.nav-sep {{ color: var(--border); }}
|
||||||
|
.nav-title {{ color: var(--text); font-weight: 500; font-size: 0.9rem; }}
|
||||||
|
.nav-right {{ margin-left: auto; }}
|
||||||
|
.nav-back {{ color: var(--muted); font-size: 0.85rem; text-decoration: none; }}
|
||||||
|
.nav-back:hover {{ color: var(--text); }}
|
||||||
|
.main {{ max-width: 1100px; margin: 0 auto; padding: 2.5rem 2rem 4rem; }}
|
||||||
|
h1 {{ font-size: 1.5rem; font-weight: 600; color: var(--text); margin-bottom: 0.2rem; }}
|
||||||
|
h2 {{ color: var(--muted); font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; margin: 2rem 0 0.875rem; }}
|
||||||
|
.desc {{ color: var(--muted); font-size: 0.9rem; margin: 0.3rem 0 0.2rem; }}
|
||||||
|
.meta {{ color: var(--muted); font-size: 0.82rem; margin-top: 0.2rem; margin-bottom: 0; }}
|
||||||
|
.meta a {{ color: var(--muted); text-decoration: none; }}
|
||||||
|
.meta a:hover {{ color: var(--text); }}
|
||||||
|
.controls {{ display: flex; align-items: center; gap: 1rem; margin-top: 1.5rem; margin-bottom: 0; padding: 0.75rem 1rem; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; }}
|
||||||
|
.controls span {{ font-size: 0.9rem; }}
|
||||||
|
.controls button {{ font-family: 'Inter', -apple-system, sans-serif; font-size: 0.82rem; padding: 0.35rem 0.85rem; border: 1px solid var(--border); border-radius: 6px; background: var(--dim); color: var(--text); cursor: pointer; transition: background 0.15s; }}
|
||||||
|
.controls button:hover {{ background: var(--border); }}
|
||||||
|
.controls button:disabled {{ opacity: 0.5; cursor: default; }}
|
||||||
table {{ width: 100%; border-collapse: collapse; }}
|
table {{ width: 100%; border-collapse: collapse; }}
|
||||||
td {{ padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; font-size: 0.88rem; vertical-align: top; }}
|
td {{ padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--dim); font-size: 0.875rem; vertical-align: top; }}
|
||||||
td:first-child {{ white-space: nowrap; color: #8b949e; min-width: 9rem; }}
|
td:first-child {{ white-space: nowrap; color: var(--muted); min-width: 9rem; }}
|
||||||
td:nth-child(2) {{ white-space: nowrap; min-width: 6rem; }}
|
td:nth-child(2) {{ white-space: nowrap; min-width: 6rem; }}
|
||||||
.src {{ color: #79c0ff; white-space: nowrap; min-width: 8rem; }}
|
.src {{ color: #79c0ff; white-space: nowrap; min-width: 8rem; }}
|
||||||
td:last-child {{ line-height: 1.5; }}
|
td:last-child {{ line-height: 1.5; }}
|
||||||
.empty {{ color: #8b949e; font-size: 0.9rem; padding: 1rem 0; }}
|
.empty {{ color: var(--muted); font-size: 0.9rem; padding: 1rem 0; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<nav class="site-nav">
|
||||||
|
<a class="nav-brand" href="/">◈ NxM</a>
|
||||||
|
<span class="nav-sep">·</span>
|
||||||
|
<span class="nav-title">Raven — Notifications</span>
|
||||||
|
<span class="nav-right"><a class="nav-back" href="/">← home</a></span>
|
||||||
|
</nav>
|
||||||
|
<main class="main">
|
||||||
<h1>Raven — Notifications</h1>
|
<h1>Raven — Notifications</h1>
|
||||||
<p class="desc">Sends alerts and notifications via Discord and email. Triggered by Varys (service down/up) and Grafana (infrastructure alerts).</p>
|
<p class="desc">Sends alerts and notifications via Discord and email. Triggered by Varys (service down/up) and Grafana (infrastructure alerts).</p>
|
||||||
<p class="meta">Updated <span data-utc="{timestamp}"></span> · {total} total · <a href="/">← home</a></p>
|
<p class="meta">Updated <span data-utc="{timestamp}"></span> · {total} total</p>
|
||||||
|
<div class="controls">
|
||||||
|
<span>Email alerts: <strong style="color:{email_color}">{email_label}</strong></span>
|
||||||
|
<button id="toggle-btn" onclick="toggleEmail()">{btn_label}</button>
|
||||||
|
</div>
|
||||||
<h2>Recent Notifications (last 20)</h2>
|
<h2>Recent Notifications (last 20)</h2>
|
||||||
{"<table><tbody>" + rows + "</tbody></table>" if rows else '<p class="empty">No notifications yet.</p>'}
|
{"<table><tbody>" + rows + "</tbody></table>" if rows else '<p class="empty">No notifications yet.</p>'}
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('[data-utc]').forEach(el => {{
|
document.querySelectorAll('[data-utc]').forEach(el => {{
|
||||||
el.textContent = new Date(el.dataset.utc).toLocaleString(undefined, {{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}});
|
el.textContent = new Date(el.dataset.utc).toLocaleString(undefined, {{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}});
|
||||||
}});
|
}});
|
||||||
|
function toggleEmail() {{
|
||||||
|
const btn = document.getElementById('toggle-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/raven/toggle', {{method: 'POST'}})
|
||||||
|
.then(() => location.reload())
|
||||||
|
.catch(e => {{ alert('Toggle failed: ' + e); btn.disabled = false; }});
|
||||||
|
}}
|
||||||
</script>
|
</script>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
|
|
||||||
@@ -163,9 +227,10 @@ def _render_md(history: list, timestamp: str) -> str:
|
|||||||
|
|
||||||
def _write_status(history: list, last_event: dict = None):
|
def _write_status(history: list, last_event: dict = None):
|
||||||
timestamp = datetime.now(timezone.utc).isoformat()
|
timestamp = datetime.now(timezone.utc).isoformat()
|
||||||
|
email_enabled = _load_state().get("email_enabled", True)
|
||||||
out_dir = SITES_DIR / "raven"
|
out_dir = SITES_DIR / "raven"
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
(out_dir / "index.html").write_text(_render_html(history, timestamp))
|
(out_dir / "index.html").write_text(_render_html(history, timestamp, email_enabled))
|
||||||
(out_dir / "last-output.md").write_text(_render_md(history, timestamp))
|
(out_dir / "last-output.md").write_text(_render_md(history, timestamp))
|
||||||
|
|
||||||
log_dir = AGENT_OS_DIR / "logs" / "raven-notify"
|
log_dir = AGENT_OS_DIR / "logs" / "raven-notify"
|
||||||
@@ -209,6 +274,13 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(title="raven-notify", lifespan=lifespan)
|
app = FastAPI(title="raven-notify", lifespan=lifespan)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["POST"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health():
|
def health():
|
||||||
@@ -220,15 +292,32 @@ def handle_notify(req: NotifyRequest):
|
|||||||
return _do_notify(req.message, req.severity, req.source)
|
return _do_notify(req.message, req.severity, req.source)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/email/toggle")
|
||||||
|
def toggle_email():
|
||||||
|
state = _load_state()
|
||||||
|
state["email_enabled"] = not state.get("email_enabled", True)
|
||||||
|
_save_state(state)
|
||||||
|
_write_status(_load_history())
|
||||||
|
logger.info(f"email alerts {'enabled' if state['email_enabled'] else 'disabled'} via toggle")
|
||||||
|
return {"ok": True, "email_enabled": state["email_enabled"]}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/webhook/grafana")
|
@app.post("/webhook/grafana")
|
||||||
def handle_grafana(payload: dict):
|
def handle_grafana(payload: dict):
|
||||||
status = payload.get("status", "firing")
|
status = payload.get("status", "firing")
|
||||||
alerts = payload.get("alerts", [])
|
alerts = payload.get("alerts", [])
|
||||||
if alerts:
|
if alerts:
|
||||||
for alert in alerts:
|
for alert in alerts:
|
||||||
|
alert_status = alert.get("status", status)
|
||||||
name = alert.get("labels", {}).get("alertname", "unknown")
|
name = alert.get("labels", {}).get("alertname", "unknown")
|
||||||
msg = alert.get("annotations", {}).get("description", name)
|
if alert_status == "resolved":
|
||||||
sev = "critical" if alert.get("status", status) == "firing" else "info"
|
# Grafana always sends the firing annotation text in resolved payloads —
|
||||||
|
# override with a clear recovery message so Discord/email isn't confusing.
|
||||||
|
msg = f"✅ RESOLVED: {name} — alert has cleared, service is back online."
|
||||||
|
sev = "info"
|
||||||
|
else:
|
||||||
|
msg = alert.get("annotations", {}).get("description", name)
|
||||||
|
sev = "critical"
|
||||||
_do_notify(msg, sev, f"grafana/{name}")
|
_do_notify(msg, sev, f"grafana/{name}")
|
||||||
return {"ok": True, "processed": len(alerts)}
|
return {"ok": True, "processed": len(alerts)}
|
||||||
msg = payload.get("message", "") or payload.get("title", "Grafana Alert")
|
msg = payload.get("message", "") or payload.get("title", "Grafana Alert")
|
||||||
@@ -239,3 +328,45 @@ def handle_grafana(payload: dict):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Jon Snow approval gate — action notification endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ActionNotifyRequest(BaseModel):
|
||||||
|
description: str
|
||||||
|
approve_url: str
|
||||||
|
reject_url: str
|
||||||
|
action_type: str
|
||||||
|
source: str = "jon-snow"
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/notify-with-actions")
|
||||||
|
def handle_action_notify(req: ActionNotifyRequest):
|
||||||
|
"""Send a Discord embed with clickable approve/reject links for Jon Snow's approval gate."""
|
||||||
|
if not DISCORD_WEBHOOK_URL:
|
||||||
|
logger.warning("DISCORD_WEBHOOK_URL not set — skipping Discord")
|
||||||
|
return {"ok": False, "error": "no webhook configured"}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"embeds": [{
|
||||||
|
"title": "⚡ Action Approval Required",
|
||||||
|
"description": f"**{req.source}** wants to: `{req.description}`",
|
||||||
|
"color": 16776960, # yellow
|
||||||
|
"fields": [
|
||||||
|
{"name": "Action Type", "value": f"`{req.action_type}`", "inline": True},
|
||||||
|
{"name": "Expires", "value": "15 minutes", "inline": True},
|
||||||
|
],
|
||||||
|
"footer": {"text": "jon-snow approval gate · nxm.co.za"},
|
||||||
|
}],
|
||||||
|
"content": f"✅ **Approve:** {req.approve_url}\n❌ **Reject:** {req.reject_url}",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
r = httpx.post(DISCORD_WEBHOOK_URL, json=payload, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
logger.info(f"discord: action approval request sent for {req.action_type}")
|
||||||
|
return {"ok": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"discord action notify failed: {e}")
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|||||||
Reference in New Issue
Block a user