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}
+
+ )}