diff --git a/README.md b/README.md index 109c5c6..fe56a84 100644 --- a/README.md +++ b/README.md @@ -48,14 +48,13 @@ curl. It does **not** require OrbStack, Kubernetes, Falco, Cilium, an API key, o infrastructure. ```bash -make setup-local make demo-local ``` Then open **http://127.0.0.1:5173**. -`make demo-local` starts the backend and UI, waits for the backend health check, and -generates ten randomized incidents. Press `Ctrl-C` to stop both services. For every +`make demo-local` installs missing dependencies, starts the backend and UI, waits for +the backend health check, and generates ten randomized incidents. Press `Ctrl-C` to stop both services. For every command, expected result, testing mode, and troubleshooting path, follow the **[Getting Started guide](docs/GETTING_STARTED.md)**. 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/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 68060df..211cc44 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -23,26 +23,17 @@ Falco, Cilium, or Kyverno evidence. No API key, OrbStack VM, Kubernetes cluster, Falco, Cilium, or Kyverno installation is required. -### 1. Install dependencies +### 1. Launch the populated demo From the repository root: -```bash -make setup-local -``` - -This creates `.venv`, installs the agent requirements, and installs the locked UI -dependencies with `npm ci`. - -### 2. Launch the populated demo - ```bash make demo-local ``` The command performs the complete startup sequence: -1. Verify local dependencies. +1. Install missing Python and UI dependencies. 2. Confirm ports 8000 and 5173 are available. 3. Start the FastAPI agent. 4. Start the Vite console. @@ -53,7 +44,9 @@ The command performs the complete startup sequence: Open [http://127.0.0.1:5173](http://127.0.0.1:5173). Press `Ctrl-C` in the terminal to stop both services. -### 3. Generate another batch +`make setup-local` remains available if you prefer to install dependencies separately. + +### 2. Generate another batch While `make demo-local` is running, use a second terminal: diff --git a/scripts/demo-local.sh b/scripts/demo-local.sh index ced0acf..b7f85af 100644 --- a/scripts/demo-local.sh +++ b/scripts/demo-local.sh @@ -29,10 +29,15 @@ stop_cleanly() { trap stop_cleanly INT TERM trap cleanup EXIT -if [[ ! -x "${repo_root}/.venv/bin/python" || ! -d "${repo_root}/ui/node_modules" ]]; then - echo "Local dependencies are not installed." - echo "Run: make setup-local" - exit 1 +if [[ ! -x "${repo_root}/.venv/bin/python" ]]; then + echo "Python environment missing; creating it now ..." + python3 -m venv "${repo_root}/.venv" + "${repo_root}/.venv/bin/pip" install -r "${repo_root}/agent/requirements.txt" +fi + +if [[ ! -d "${repo_root}/ui/node_modules" ]]; then + echo "UI dependencies missing; installing them now ..." + npm --prefix "${repo_root}/ui" ci fi if curl --fail --silent http://127.0.0.1:8000/health >/dev/null 2>&1; then @@ -48,7 +53,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=$! diff --git a/ui/src/pages/CommandCenter.tsx b/ui/src/pages/CommandCenter.tsx index 3dbd15b..fb79281 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) } } @@ -1050,43 +1062,6 @@ export default function CommandCenter() { return () => clearInterval(t) }, []) - // Seed backend incidents on first load if the backend is empty. - useEffect(() => { - const autoSimulate = async () => { - // Wait a bit for initial data fetch - await new Promise(resolve => setTimeout(resolve, 2000)) - - // If we have less than 10 incidents or they're all the same type, simulate diverse threats - if (incidents.length < 10) { - const uniqueRules = new Set(incidents.map(i => i.rule)) - if (uniqueRules.size < 3) { - console.log('Seeding backend threat stream for live dashboard data') - await simulateThreats('mixed', 20) - } - } - } - - autoSimulate() - }, []) // Run once on mount - - // Periodically add new diverse threats (every 45 seconds) - useEffect(() => { - const periodicSimulate = setInterval(async () => { - // Add 3-5 new threats periodically to keep things interesting - try { - await fetch(`${API}/simulate-threats`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ count: 3, scenario: 'mixed' }) - }) - } catch (error) { - console.error('Periodic simulation failed:', error) - } - }, 45000) // Every 45 seconds - - return () => clearInterval(periodicSimulate) - }, []) - const kpis = stats ? [ { label: 'Active (1h)', value: stats.total_1h, color: '#ff9f0a', spark: sparkData.events }, @@ -1214,6 +1189,11 @@ export default function CommandCenter() { {backendLive ? `backend live${lastRefresh ? ` · ${lastRefresh}` : ''}` : 'backend disconnected'} + {simulationStatus && ( + + {simulationStatus} + + )}