From 4f5f0fd57c4b19320d5f7d3793136cbdb434e845 Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Wed, 20 May 2026 18:02:50 +0300 Subject: [PATCH 1/3] refactor: extract shared investigation runner and improve monitoring reliability - Extract _run_investigation into src/agent/investigation_runner.py, eliminating ~130 lines of copy-paste between poller.py and event_consumer.py - Synthesise a failed result when agent completes without calling submit_investigation (handles models with weak tool-use support like Gemma) so the event is always persisted and notified rather than silently dropped - Add SSE push at GET /api/monitoring/stream; MonitoringPage switches from 30s polling to real-time alert delivery via EventSource with 30s fallback on error - Fix services grid to survive restarts by falling back to DB-derived status when in-memory dict is empty; fix status mapping so completed investigations show as healthy (green) instead of amber - Isolate poller boto3 calls to a dedicated ThreadPoolExecutor(max_workers=4) so poller ticks cannot saturate the shared default pool used by API requests --- frontend/src/pages/MonitoringPage.tsx | 44 ++++++- src/agent/event_consumer.py | 134 +------------------- src/agent/investigation_runner.py | 170 ++++++++++++++++++++++++++ src/agent/monitor_store.py | 64 +++++++++- src/agent/poller.py | 144 ++-------------------- src/api/routers/monitoring.py | 43 ++++++- 6 files changed, 327 insertions(+), 272 deletions(-) create mode 100644 src/agent/investigation_runner.py diff --git a/frontend/src/pages/MonitoringPage.tsx b/frontend/src/pages/MonitoringPage.tsx index 6f66c7a..b9c85e6 100644 --- a/frontend/src/pages/MonitoringPage.tsx +++ b/frontend/src/pages/MonitoringPage.tsx @@ -60,7 +60,7 @@ export default function MonitoringPage() { toast.error(d.detail || 'Failed to send test event'); return; } - toast.success('Test Lambda alarm sent — investigation will appear here in ~30s'); + toast.success('Test Lambda alarm sent — investigation will appear here automatically'); } catch { toast.error('Failed to send test event'); } finally { @@ -76,7 +76,47 @@ export default function MonitoringPage() { .finally(() => setLoading(false)); }; - useEffect(() => { load(); const id = setInterval(load, 30000); return () => clearInterval(id); }, []); + useEffect(() => { + load(); + + // Subscribe to real-time alert push via SSE + let es: EventSource | null = null; + let fallbackId: ReturnType | null = null; + + const connectSSE = () => { + es = new EventSource('/api/monitoring/stream'); + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + if (data.type === 'alert') { + setAlerts(prev => + prev.some(a => a.id === data.alert.id) ? prev : [data.alert, ...prev] + ); + } + } catch { /* ignore parse errors */ } + }; + es.onerror = () => { + es?.close(); + es = null; + // Fall back to polling if SSE fails + if (!fallbackId) fallbackId = setInterval(load, 30000); + }; + }; + + connectSSE(); + + // Always poll services (not SSE-pushed yet) + const serviceId = setInterval( + () => fetchServices().then(setServices).catch(() => {}), + 30000, + ); + + return () => { + es?.close(); + if (fallbackId) clearInterval(fallbackId); + clearInterval(serviceId); + }; + }, []); const healthy = services.filter(s => s.status === 'healthy').length; const errored = services.filter(s => s.status === 'error').length; diff --git a/src/agent/event_consumer.py b/src/agent/event_consumer.py index 5a1654c..061a875 100644 --- a/src/agent/event_consumer.py +++ b/src/agent/event_consumer.py @@ -111,137 +111,9 @@ def _build_investigation_prompt(event: dict) -> str: async def _run_investigation(prompt: str, session_id: str) -> dict[str, Any] | None: - """Run a full agent investigation using the same streaming path as the chat endpoint.""" - from agent.core import get_agent - from agent.turns import save_turn - - def _f(obj: Any, key: str, default: Any = None) -> Any: - return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default) - - config = { - "configurable": {"thread_id": session_id}, - "recursion_limit": settings.max_tool_calls * 3 + 15, - } - - tc_accum: dict[int, dict[str, Any]] = {} - pending_calls: dict[str, dict[str, Any]] = {} - tool_calls_log: list[dict[str, Any]] = [] - investigation_result: dict[str, Any] | None = None - response_text = "" - usage_meta: Any = None - - try: - async with asyncio.timeout(settings.investigation_timeout): - async for chunk, _meta in get_agent().astream( - {"messages": [{"role": "user", "content": prompt}]}, - config=config, - stream_mode="messages", - ): - um = getattr(chunk, "usage_metadata", None) - if um: - usage_meta = um - - for tcc in getattr(chunk, "tool_call_chunks", []) or []: - idx = _f(tcc, "index", 0) - if idx not in tc_accum: - tc_accum[idx] = {"id": "", "name": "", "args_str": ""} - if tc_id := _f(tcc, "id"): - tc_accum[idx]["id"] = tc_id - if name := (_f(tcc, "name") or ""): - tc_accum[idx]["name"] += name - if args := (_f(tcc, "args") or ""): - tc_accum[idx]["args_str"] += args - - for tc in getattr(chunk, "tool_calls", []) or []: - tc_id = _f(tc, "id") or "" - name = _f(tc, "name") or "" - args = _f(tc, "args") or {} - if tc_id and name: - pending_calls[tc_id] = { - "tool": name, - "args": args if isinstance(args, dict) else {}, - } - - content = getattr(chunk, "content", "") - tc_id = getattr(chunk, "tool_call_id", None) - - if content and isinstance(content, str) and not tc_id: - response_text += content - - if tc_id: - for entry in tc_accum.values(): - if eid := entry["id"]: - try: - eargs: Any = ( - json.loads(entry["args_str"]) if entry["args_str"] else {} - ) - except json.JSONDecodeError: - eargs = {} - pending_calls[eid] = {"tool": entry["name"], "args": eargs} - tc_accum.clear() - - call_info = pending_calls.pop( - tc_id, - {"tool": getattr(chunk, "name", None) or "unknown", "args": {}}, - ) - try: - result = json.loads(content) - except (json.JSONDecodeError, TypeError): - result = {"raw": str(content)[:500]} - - tool_calls_log.append({ - "tool": call_info["tool"], - "args": call_info["args"], - "result": result, - }) - if call_info["tool"] == "submit_investigation": - investigation_result = call_info["args"] - - except Exception as e: - logger.error("Agent investigation failed: {}", e) - investigation_result = { - "_status": "failed", - "root_cause_summary": f"Investigation failed: {e}", - "confidence": "LOW", - "mitigation_steps": [ - "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.", - ], - } - - # Build readable assistant summary - if ( - investigation_result - and investigation_result.get("root_cause_summary") - and "_status" not in investigation_result - ): - steps = investigation_result.get("mitigation_steps", []) - steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._" - confidence = investigation_result.get("confidence", "MEDIUM") - assistant_text = ( - f"**Root Cause ({confidence} confidence):** " - f"{investigation_result['root_cause_summary']}\n\n" - f"**Mitigation Steps:**\n{steps_text}" - ) - elif response_text.strip(): - assistant_text = response_text.strip() - else: - assistant_text = "Investigation completed. See tool calls for details." - - await save_turn( - session_id=session_id, - user_message=prompt, - assistant_text=assistant_text, - tool_calls_log=tool_calls_log, - usage={ - "model": settings.llm_model, - "input_tokens": _f(usage_meta, "input_tokens", 0) or 0, - "output_tokens": _f(usage_meta, "output_tokens", 0) or 0, - "latency_ms": 0, - }, - source="event", - ) - - return investigation_result + from agent.investigation_runner import run_investigation + result, _tool_calls_log = await run_investigation(prompt, session_id) + return result def _extract_aws_error(event: dict) -> str: diff --git a/src/agent/investigation_runner.py b/src/agent/investigation_runner.py new file mode 100644 index 0000000..32c8e7e --- /dev/null +++ b/src/agent/investigation_runner.py @@ -0,0 +1,170 @@ +"""Shared investigation runner — used by both the poller and event consumer.""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any + +from loguru import logger + +from config import settings + + +async def run_investigation( + prompt: str, session_id: str +) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: + """Run a full agent investigation. Returns (result, tool_calls_log). + + Both the poller and the event consumer use this. result is None only when the + LLM never called submit_investigation (e.g. MAX_TOOL_CALLS hit without finishing). + On agent exception, result is returned with _status="failed" so callers can still + persist a record and send notifications. + """ + from agent.core import get_agent + from agent.turns import save_turn + + def _f(obj: Any, key: str, default: Any = None) -> Any: + return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default) + + config = { + "configurable": {"thread_id": session_id}, + "recursion_limit": settings.max_tool_calls * 3 + 15, + } + + tc_accum: dict[int, dict[str, Any]] = {} + pending_calls: dict[str, dict[str, Any]] = {} + tool_calls_log: list[dict[str, Any]] = [] + investigation_result: dict[str, Any] | None = None + response_text = "" + usage_meta: Any = None + + try: + async with asyncio.timeout(settings.investigation_timeout): + async for chunk, _meta in get_agent().astream( + {"messages": [{"role": "user", "content": prompt}]}, + config=config, + stream_mode="messages", + ): + um = getattr(chunk, "usage_metadata", None) + if um: + usage_meta = um + + for tcc in getattr(chunk, "tool_call_chunks", []) or []: + idx = _f(tcc, "index", 0) + if idx not in tc_accum: + tc_accum[idx] = {"id": "", "name": "", "args_str": ""} + if tc_id := _f(tcc, "id"): + tc_accum[idx]["id"] = tc_id + if name := (_f(tcc, "name") or ""): + tc_accum[idx]["name"] += name + if args := (_f(tcc, "args") or ""): + tc_accum[idx]["args_str"] += args + + for tc in getattr(chunk, "tool_calls", []) or []: + tc_id = _f(tc, "id") or "" + name = _f(tc, "name") or "" + args = _f(tc, "args") or {} + if tc_id and name: + pending_calls[tc_id] = { + "tool": name, + "args": args if isinstance(args, dict) else {}, + } + + content = getattr(chunk, "content", "") + tc_id = getattr(chunk, "tool_call_id", None) + + if content and isinstance(content, str) and not tc_id: + response_text += content + + if tc_id: + for entry in tc_accum.values(): + if eid := entry["id"]: + try: + eargs: Any = ( + json.loads(entry["args_str"]) if entry["args_str"] else {} + ) + except json.JSONDecodeError: + eargs = {} + pending_calls[eid] = {"tool": entry["name"], "args": eargs} + tc_accum.clear() + + call_info = pending_calls.pop( + tc_id, + {"tool": getattr(chunk, "name", None) or "unknown", "args": {}}, + ) + try: + result = json.loads(content) + except (json.JSONDecodeError, TypeError): + result = {"raw": str(content)[:500]} + + tool_calls_log.append({ + "tool": call_info["tool"], + "args": call_info["args"], + "result": result, + }) + if call_info["tool"] == "submit_investigation": + investigation_result = call_info["args"] + + except Exception as e: + logger.error("Investigation failed: {}", e) + investigation_result = { + "_status": "failed", + "root_cause_summary": f"Investigation failed: {e}", + "confidence": "LOW", + "mitigation_steps": [ + "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.", + ], + } + + # Agent completed without calling submit_investigation (e.g. hit recursion limit or + # the model doesn't support tool use reliably). Synthesize a failed result so callers + # always get a non-None value and can still persist + notify. + if investigation_result is None: + summary = response_text.strip() or "Agent completed without producing a structured result." + logger.warning( + "Investigation ended without submit_investigation — synthesising failed result" + ) + investigation_result = { + "_status": "failed", + "root_cause_summary": summary[:500], + "confidence": "LOW", + "mitigation_steps": [ + "Re-investigate manually via the chat.", + "Consider switching to a model with stronger tool-use support.", + ], + } + + if ( + investigation_result + and investigation_result.get("root_cause_summary") + and "_status" not in investigation_result + ): + steps = investigation_result.get("mitigation_steps", []) + steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._" + confidence = investigation_result.get("confidence", "MEDIUM") + assistant_text = ( + f"**Root Cause ({confidence} confidence):** " + f"{investigation_result['root_cause_summary']}\n\n" + f"**Mitigation Steps:**\n{steps_text}" + ) + elif response_text.strip(): + assistant_text = response_text.strip() + else: + assistant_text = "Investigation completed. See tool calls for details." + + await save_turn( + session_id=session_id, + user_message=prompt, + assistant_text=assistant_text, + tool_calls_log=tool_calls_log, + usage={ + "model": settings.llm_model, + "input_tokens": _f(usage_meta, "input_tokens", 0) or 0, + "output_tokens": _f(usage_meta, "output_tokens", 0) or 0, + "latency_ms": 0, + }, + source="event", + ) + + return investigation_result, tool_calls_log diff --git a/src/agent/monitor_store.py b/src/agent/monitor_store.py index 4006217..ef652f6 100644 --- a/src/agent/monitor_store.py +++ b/src/agent/monitor_store.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from datetime import UTC, datetime from loguru import logger @@ -9,6 +10,27 @@ # In-memory service status (ephemeral — just current state) _services: dict[str, dict] = {} +# SSE subscribers — one Queue per connected /api/monitoring/stream client +_alert_subscribers: set[asyncio.Queue[dict | None]] = set() + + +def subscribe_alerts() -> asyncio.Queue[dict | None]: + q: asyncio.Queue[dict | None] = asyncio.Queue(maxsize=50) + _alert_subscribers.add(q) + return q + + +def unsubscribe_alerts(q: asyncio.Queue[dict | None]) -> None: + _alert_subscribers.discard(q) + + +def _broadcast_alert(alert: dict) -> None: + for q in list(_alert_subscribers): + try: + q.put_nowait(alert) + except asyncio.QueueFull: + pass + async def add_alert( service: str, @@ -22,14 +44,30 @@ async def add_alert( trigger_source: str | None = None, evidence: list | None = None, ) -> str: - """Persist an alert to the DB backend.""" + """Persist an alert to the DB backend and broadcast to SSE subscribers.""" from agent.db import db try: - return await db.add_alert( + alert_id = await db.add_alert( service, error, resolution, confidence, sns_sent, dedup_key, status, session_id, trigger_source, evidence, ) + if alert_id and _alert_subscribers: + _broadcast_alert({ + "id": alert_id, + "service": service, + "error": error, + "resolution": resolution, + "confidence": confidence, + "sns_sent": sns_sent, + "timestamp": datetime.now(UTC).isoformat(), + "dedup_key": dedup_key, + "status": status, + "trigger_source": trigger_source, + "session_id": session_id, + "evidence": evidence or [], + }) + return alert_id except Exception as e: logger.error("Failed to persist alert: {}", e) return "" @@ -109,7 +147,7 @@ async def is_incident_claimed(incident_key: str, within_minutes: int = 3) -> boo def update_service(name: str, status: str, error: str | None = None) -> None: _services[name] = { "name": name, - "status": status, + "status": "error" if status == "failed" else "healthy", "last_check": datetime.now(UTC).isoformat(), "last_error": error, } @@ -127,5 +165,21 @@ async def get_alert_async(alert_id: str) -> dict | None: return await db.get_alert(alert_id) -def get_services() -> list[dict]: - return list(_services.values()) +async def get_services() -> list[dict]: + """Return service health. Uses in-memory dict if populated; falls back to DB on restart.""" + if _services: + return list(_services.values()) + # After restart the dict is empty — derive last-known status per service from persisted alerts. + alerts = await get_alerts_async(limit=200) + seen: dict[str, dict] = {} + for a in alerts: + svc = a.get("service") or "" + if svc and svc not in seen: + alert_status = a.get("status", "completed") + seen[svc] = { + "name": svc, + "status": "error" if alert_status == "failed" else "healthy", + "last_check": a.get("timestamp") or "", + "last_error": a.get("error") if alert_status == "failed" else None, + } + return list(seen.values()) diff --git a/src/agent/poller.py b/src/agent/poller.py index 4997279..3805265 100644 --- a/src/agent/poller.py +++ b/src/agent/poller.py @@ -10,13 +10,20 @@ from __future__ import annotations import asyncio +from concurrent.futures import ThreadPoolExecutor from typing import Any from loguru import logger -from agent.incident_keys import alarm_incident_key, lambda_error_incident_key, lambda_metric_incident_key +from agent.incident_keys import ( + alarm_incident_key, + lambda_error_incident_key, +) from config import settings +# Dedicated pool so poller boto3 calls never compete with API request threads +_POLLER_POOL = ThreadPoolExecutor(max_workers=4, thread_name_prefix="poller") + def _claim_window_minutes() -> int: return max(1, settings.poll_reinvestigate_hours * 60) @@ -25,135 +32,8 @@ def _claim_window_minutes() -> int: async def _run_investigation( prompt: str, session_id: str ) -> tuple[dict[str, Any] | None, list[dict[str, Any]]]: - """Run a full agent investigation using the same streaming path as the chat endpoint.""" - import json - - from agent.core import get_agent - from agent.turns import save_turn - - def _f(obj: Any, key: str, default: Any = None) -> Any: - return obj.get(key, default) if isinstance(obj, dict) else getattr(obj, key, default) - - config = { - "configurable": {"thread_id": session_id}, - "recursion_limit": settings.max_tool_calls * 3 + 15, - } - - tc_accum: dict[int, dict[str, Any]] = {} - pending_calls: dict[str, dict[str, Any]] = {} - tool_calls_log: list[dict[str, Any]] = [] - investigation_result: dict[str, Any] | None = None - response_text = "" - usage_meta: Any = None - - try: - async with asyncio.timeout(settings.investigation_timeout): - async for chunk, _meta in get_agent().astream( - {"messages": [{"role": "user", "content": prompt}]}, - config=config, - stream_mode="messages", - ): - um = getattr(chunk, "usage_metadata", None) - if um: - usage_meta = um - - for tcc in getattr(chunk, "tool_call_chunks", []) or []: - idx = _f(tcc, "index", 0) - if idx not in tc_accum: - tc_accum[idx] = {"id": "", "name": "", "args_str": ""} - if tc_id := _f(tcc, "id"): - tc_accum[idx]["id"] = tc_id - if name := (_f(tcc, "name") or ""): - tc_accum[idx]["name"] += name - if args := (_f(tcc, "args") or ""): - tc_accum[idx]["args_str"] += args - - for tc in getattr(chunk, "tool_calls", []) or []: - tc_id = _f(tc, "id") or "" - name = _f(tc, "name") or "" - args = _f(tc, "args") or {} - if tc_id and name: - pending_calls[tc_id] = { - "tool": name, - "args": args if isinstance(args, dict) else {}, - } - - content = getattr(chunk, "content", "") - tc_id = getattr(chunk, "tool_call_id", None) - - if content and isinstance(content, str) and not tc_id: - response_text += content - - if tc_id: - for entry in tc_accum.values(): - if eid := entry["id"]: - try: - eargs: Any = ( - json.loads(entry["args_str"]) if entry["args_str"] else {} - ) - except json.JSONDecodeError: - eargs = {} - pending_calls[eid] = {"tool": entry["name"], "args": eargs} - tc_accum.clear() - - call_info = pending_calls.pop( - tc_id, - {"tool": getattr(chunk, "name", None) or "unknown", "args": {}}, - ) - try: - result = json.loads(content) - except (json.JSONDecodeError, TypeError): - result = {"raw": str(content)[:500]} - - tool_calls_log.append({ - "tool": call_info["tool"], - "args": call_info["args"], - "result": result, - }) - if call_info["tool"] == "submit_investigation": - investigation_result = call_info["args"] - - except Exception as e: - logger.error("Poller investigation failed: {}", e) - investigation_result = { - "_status": "failed", - "root_cause_summary": f"Investigation failed: {e}", - "confidence": "LOW", - "mitigation_steps": [ - "Re-investigate manually via the chat — use a higher MAX_TOOL_CALLS if needed.", - ], - } - - if (investigation_result and investigation_result.get("root_cause_summary") - and "_status" not in investigation_result): - steps = investigation_result.get("mitigation_steps", []) - steps_text = "\n".join(f"- {s}" for s in steps) if steps else "_No steps provided._" - confidence = investigation_result.get("confidence", "MEDIUM") - assistant_text = ( - f"**Root Cause ({confidence} confidence):** " - f"{investigation_result['root_cause_summary']}\n\n" - f"**Mitigation Steps:**\n{steps_text}" - ) - elif response_text.strip(): - assistant_text = response_text.strip() - else: - assistant_text = "Investigation completed. See tool calls for details." - - await save_turn( - session_id=session_id, - user_message=prompt, - assistant_text=assistant_text, - tool_calls_log=tool_calls_log, - usage={ - "model": settings.llm_model, - "input_tokens": _f(usage_meta, "input_tokens", 0) or 0, - "output_tokens": _f(usage_meta, "output_tokens", 0) or 0, - "latency_ms": 0, - }, - source="event", - ) - - return investigation_result, tool_calls_log + from agent.investigation_runner import run_investigation + return await run_investigation(prompt, session_id) async def _persist_and_notify( @@ -231,7 +111,7 @@ async def _check_alarms() -> None: ) from tools.cloudwatch import get_alarms - data = await asyncio.get_event_loop().run_in_executor(None, get_alarms, "ALARM") + data = await asyncio.get_event_loop().run_in_executor(_POLLER_POOL, get_alarms, "ALARM") alarms = data.get("alarms", []) logger.debug("Poller: {} alarm(s) in ALARM state", len(alarms)) for alarm in alarms: @@ -305,7 +185,7 @@ async def _check_lambda_errors() -> None: ) from tools.lambda_ import get_lambda_error_rate, list_lambda_functions - funcs = await asyncio.get_event_loop().run_in_executor(None, list_lambda_functions) + funcs = await asyncio.get_event_loop().run_in_executor(_POLLER_POOL, list_lambda_functions) fn_list = funcs.get("functions", [])[:20] logger.debug("Poller: checking error rates for {} Lambda function(s)", len(fn_list)) for fn in fn_list: # cap at 20 to avoid runaway API calls diff --git a/src/api/routers/monitoring.py b/src/api/routers/monitoring.py index 0aac9b7..ef59c9f 100644 --- a/src/api/routers/monitoring.py +++ b/src/api/routers/monitoring.py @@ -2,11 +2,20 @@ from __future__ import annotations +import asyncio +import json from typing import Annotated from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import StreamingResponse -from agent.monitor_store import get_alerts_async, get_alert_async, get_services +from agent.monitor_store import ( + get_alert_async, + get_alerts_async, + get_services, # async — returns in-memory or falls back to DB + subscribe_alerts, + unsubscribe_alerts, +) from api.auth import get_current_user router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) @@ -16,7 +25,7 @@ async def services( _user: Annotated[dict | None, Depends(get_current_user)], ) -> list: - return get_services() + return await get_services() @router.get("/alerts") @@ -36,3 +45,33 @@ async def alert_detail( if not alert: raise HTTPException(status_code=404, detail="Alert not found") return alert + + +@router.get("/stream") +async def monitoring_stream( + _user: Annotated[dict | None, Depends(get_current_user)], +) -> StreamingResponse: + """SSE stream — pushes new alerts in real time as they are persisted.""" + + async def generate(): + q = subscribe_alerts() + try: + yield f"data: {json.dumps({'type': 'connected'})}\n\n" + while True: + try: + alert = await asyncio.wait_for(q.get(), timeout=20.0) + if alert is None: + break + yield f"data: {json.dumps({'type': 'alert', 'alert': alert})}\n\n" + except TimeoutError: + yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n" + except asyncio.CancelledError: + pass + finally: + unsubscribe_alerts(q) + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) From aaef6934c1adc4f073d92d7c05f4887391530f21 Mon Sep 17 00:00:00 2001 From: AhmadHammad21 Date: Wed, 20 May 2026 18:40:18 +0300 Subject: [PATCH 2/3] feat: remove SNS integration SNS was fully optional (poller never used it, event consumer gated on sns_topic_arn being set) and added IAM surface area with no benefit over the existing Slack and Telegram channels. - Delete src/tools/sns.py - Remove SNS block from event_consumer._deliver(); sns_sent hardcoded False - Remove sns_topic_arn from appsettings, init_store (_default + accessor), SetupBody, and /api/init/setup handler - Remove SNS permission check from permission_checker.check_permissions() - Remove SNS field from SettingsPage AWS Configuration tab and InitPage wizard - Remove SNS_TOPIC_ARN from .env.example - Clean up permission_checker line lengths while touching the file --- .env.example | 3 +- frontend/src/pages/InitPage.tsx | 18 +-- frontend/src/pages/SettingsPage.tsx | 7 +- scripts/test_dlq.py | 182 +++++++++++++++++++++++++ src/agent/event_consumer.py | 40 +----- src/agent/init_store.py | 4 - src/agent/permission_checker.py | 44 +++---- src/api/routers/init.py | 6 +- src/config/appsettings.py | 5 +- src/tools/sns.py | 23 ---- tests/integration/test_dlq_logic.py | 197 ++++++++++++++++++++++++++++ 11 files changed, 411 insertions(+), 118 deletions(-) create mode 100644 scripts/test_dlq.py delete mode 100644 src/tools/sns.py create mode 100644 tests/integration/test_dlq_logic.py diff --git a/.env.example b/.env.example index a626f4b..95e5a1b 100644 --- a/.env.example +++ b/.env.example @@ -70,7 +70,6 @@ CHECKPOINT_BACKEND=memory # POLL_ERROR_THRESHOLD=5.0 # Lambda error rate % that triggers an investigation # POLL_REINVESTIGATE_HOURS=1 # don't re-investigate the same alarm within N hours -# Event-driven detection — configure SNS/SQS via Settings → AWS Configuration after login -# SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789012:opendevops-alerts +# Event-driven detection — configure SQS via Settings → AWS Configuration after login # SQS_QUEUE_URL=https://sqs.us-east-1.amazonaws.com/123456789012/opendevops # EVENT_CONSUMER_ENABLED=false diff --git a/frontend/src/pages/InitPage.tsx b/frontend/src/pages/InitPage.tsx index cefa2bc..e07c18c 100644 --- a/frontend/src/pages/InitPage.tsx +++ b/frontend/src/pages/InitPage.tsx @@ -57,7 +57,6 @@ export default function InitPage() { // Step 2 const [region, setRegion] = useState('us-east-1'); - const [snsArn, setSnsArn] = useState(''); // Step 3 const [perms, setPerms] = useState>({}); @@ -78,7 +77,6 @@ export default function InitPage() { .then(r => r.json()) .then(d => { setRegion(d.aws_region || 'us-east-1'); - setSnsArn(d.sns_topic_arn || ''); if (d.initialized && (d.has_user || !d.auth_enabled)) { navigate('/', { replace: true }); return; @@ -114,7 +112,7 @@ export default function InitPage() { const r = await apiFetch('/api/init/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ aws_region: region, sns_topic_arn: snsArn, sqs_queue_url: '', org_name: orgName.trim() }), + body: JSON.stringify({ aws_region: region, sqs_queue_url: '', org_name: orgName.trim() }), }); if (!r.ok) { setError('Failed to save configuration'); return; } setStep(3); @@ -279,20 +277,6 @@ export default function InitPage() { ))} -
- - setSnsArn(e.target.value)} - placeholder="arn:aws:sns:us-east-1:123456789012:alerts" - className={inp} - /> -

- Leave empty to skip SNS notifications — alerts will still appear in the monitoring page. -

-
{error &&

{error}

}