From 4bf8bcd4663184c19482822db25d42b51bbc14ec Mon Sep 17 00:00:00 2001 From: kaushik-kumaran Date: Sat, 18 Jul 2026 00:50:29 -0500 Subject: [PATCH 1/4] fix(local-demo): bypass unavailable Kubernetes discovery Mark the cluster-free launcher explicitly and return fallback telemetry without blocking the FastAPI event loop on stale Kubernetes endpoints.\n\nCloses #96 --- agent/src/main.py | 12 +++++++++- agent/tests/test_local_demo.py | 40 ++++++++++++++++++++++++++++++++++ scripts/demo-local.sh | 3 ++- 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 agent/tests/test_local_demo.py diff --git a/agent/src/main.py b/agent/src/main.py index 28cdbbf..9eb5131 100644 --- a/agent/src/main.py +++ b/agent/src/main.py @@ -75,6 +75,10 @@ def _load_kube_client_config(): return "kubeconfig" +def _is_local_demo() -> bool: + return os.getenv("ARGUS_LOCAL_DEMO", "false").lower() in {"1", "true", "yes"} + + @asynccontextmanager async def lifespan(app: FastAPI): log.info("argus_agent_starting", version="0.1.0") @@ -105,6 +109,7 @@ async def health(): "status": "ok", "service": "argus-agent", "version": "0.1.0", + "mode": "local_demo" if _is_local_demo() else "cluster", "anthropic_configured": bool(app_config.ANTHROPIC_API_KEY), "anthropic_key_hint": ( f"...{app_config.ANTHROPIC_API_KEY[-4:]}" if app_config.ANTHROPIC_API_KEY else None @@ -409,6 +414,8 @@ async def get_cluster_overview(): } try: + if _is_local_demo(): + raise RuntimeError("Kubernetes discovery disabled in local-demo mode") from kubernetes import client, config kube_mode = _load_kube_client_config() @@ -584,6 +591,8 @@ async def get_infra_observability(): generated_at = datetime.now(timezone.utc).isoformat() try: + if _is_local_demo(): + raise RuntimeError("Kubernetes discovery disabled in local-demo mode") from kubernetes import client, config if os.getenv("IN_CLUSTER", "true").lower() == "true": @@ -775,7 +784,8 @@ def event_ts(evt) -> str: } except Exception as e: - log.warning("infra_observability_k8s_fallback", error=str(e)) + if not _is_local_demo(): + log.warning("infra_observability_k8s_fallback", error=str(e)) recent = [i for i in incident_store if i.get("ts", 0) > now - 3600] namespaces = sorted({str(i.get("namespace") or "default") for i in recent} | {"default", "kube-system"}) diff --git a/agent/tests/test_local_demo.py b/agent/tests/test_local_demo.py new file mode 100644 index 0000000..d0177fe --- /dev/null +++ b/agent/tests/test_local_demo.py @@ -0,0 +1,40 @@ +"""Regression coverage for the explicitly cluster-free local demo mode.""" + +import os +import sys + +from fastapi.testclient import TestClient + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from main import app + + +client = TestClient(app) + + +def test_health_reports_local_demo_mode(monkeypatch): + monkeypatch.setenv("ARGUS_LOCAL_DEMO", "true") + + response = client.get("/health") + + assert response.status_code == 200 + assert response.json()["mode"] == "local_demo" + + +def test_cluster_overview_uses_fallback_without_kubernetes(monkeypatch): + monkeypatch.setenv("ARGUS_LOCAL_DEMO", "true") + + response = client.get("/cluster-overview") + + assert response.status_code == 200 + assert response.json()["cluster_source"] == "node_telemetry_fallback" + + +def test_infra_observability_uses_fallback_without_kubernetes(monkeypatch): + monkeypatch.setenv("ARGUS_LOCAL_DEMO", "true") + + response = client.get("/infra-observability") + + assert response.status_code == 200 + assert response.json()["source"] == "incident_fallback" diff --git a/scripts/demo-local.sh b/scripts/demo-local.sh index ced0acf..76d597a 100644 --- a/scripts/demo-local.sh +++ b/scripts/demo-local.sh @@ -48,7 +48,8 @@ fi echo "Starting Argus backend at http://127.0.0.1:8000 ..." ( cd "${repo_root}/agent/src" - exec ../../.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000 + exec env ARGUS_LOCAL_DEMO=true IN_CLUSTER=false \ + ../../.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000 ) & agent_pid=$! From 6d40cce9371fdc9bb38315b9bdbe4ddbe2407708 Mon Sep 17 00:00:00 2001 From: kaushik-kumaran Date: Sat, 18 Jul 2026 00:53:00 -0500 Subject: [PATCH 2/4] fix(ui): bound threat simulation requests Stop the command-center spinner after five seconds and surface actionable success or failure status instead of logging failures only to the console.\n\nRefs #96 --- ui/src/pages/CommandCenter.tsx | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/CommandCenter.tsx b/ui/src/pages/CommandCenter.tsx index 3dbd15b..a3e2e8e 100644 --- a/ui/src/pages/CommandCenter.tsx +++ b/ui/src/pages/CommandCenter.tsx @@ -989,6 +989,7 @@ export default function CommandCenter() { const [backendLive, setBackendLive] = useState(false) const [lastRefresh, setLastRefresh] = useState('') const [simulating, setSimulating] = useState(false) + const [simulationStatus, setSimulationStatus] = useState('') const [sparkData] = useState(() => ({ critical: Array.from({ length: 12 }, () => 0), events: Array.from({ length: 12 }, () => 0), @@ -997,23 +998,34 @@ export default function CommandCenter() { const simulateThreats = async (scenario = 'mixed', count = 20) => { setSimulating(true) + setSimulationStatus('') + const controller = new AbortController() + const timeout = window.setTimeout(() => controller.abort(), 5000) try { const response = await fetch(`${API}/simulate-threats`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ count, scenario }) + body: JSON.stringify({ count, scenario }), + signal: controller.signal, }) - if (response.ok) { - // Refresh incidents immediately - const iRes = await fetch(`${API}/incidents?limit=50`) - if (iRes.ok) { - const data = await iRes.json() - if (data.incidents) setIncidents(data.incidents) - } + if (!response.ok) throw new Error(`simulation returned ${response.status}`) + const result = await response.json() + const iRes = await fetch(`${API}/incidents?limit=50`, { signal: controller.signal }) + if (!iRes.ok) throw new Error(`incident refresh returned ${iRes.status}`) + const data = await iRes.json() + if (data.incidents) { + setIncidents(data.incidents) } + setSimulationStatus(`Injected ${result.simulated_count ?? count} threats`) } catch (error) { console.error('Failed to simulate threats:', error) + setSimulationStatus( + error instanceof DOMException && error.name === 'AbortError' + ? 'Simulation timed out — restart make demo-local' + : 'Simulation failed — check backend health' + ) } finally { + window.clearTimeout(timeout) setSimulating(false) } } @@ -1214,6 +1226,11 @@ export default function CommandCenter() { {backendLive ? `backend live${lastRefresh ? ` · ${lastRefresh}` : ''}` : 'backend disconnected'} + {simulationStatus && ( + + {simulationStatus} + + )}