Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**.

Expand Down
12 changes: 11 additions & 1 deletion agent/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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"})

Expand Down
40 changes: 40 additions & 0 deletions agent/tests/test_local_demo.py
Original file line number Diff line number Diff line change
@@ -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"
17 changes: 5 additions & 12 deletions docs/GETTING_STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:

Expand Down
16 changes: 11 additions & 5 deletions scripts/demo-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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=$!

Expand Down
70 changes: 25 additions & 45 deletions ui/src/pages/CommandCenter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -1214,6 +1189,11 @@ export default function CommandCenter() {
<span style={{ width: '5px', height: '5px', borderRadius: '50%', background: backendLive ? '#00ff9f' : '#ff9f0a', animation: backendLive ? 'liveBlink 1.4s infinite' : 'none' }} />
{backendLive ? `backend live${lastRefresh ? ` · ${lastRefresh}` : ''}` : 'backend disconnected'}
</span>
{simulationStatus && (
<span style={{ fontSize: '8px', color: simulationStatus.startsWith('Injected') ? '#00ff9f' : '#ff9f0a', fontFamily: 'JetBrains Mono, monospace' }}>
{simulationStatus}
</span>
)}
<button
onClick={() => simulateThreats('human_approval', 3)}
disabled={simulating}
Expand Down
5 changes: 4 additions & 1 deletion ui/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
// Uvicorn is deliberately bound to IPv4 by the cluster-free launcher.
// Using `localhost` here can resolve to ::1 in Node and disconnect an
// otherwise healthy backend.
target: 'http://127.0.0.1:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
Expand Down
Loading