Skip to content

Commit f9c8817

Browse files
jsell-rhclaudemergify[bot]
authored
feat(ambient-ui): session log details tab (#1633)
## Summary - **Logs tab**: Structured operational event viewer with filter chips, error summary banner, live-tail auto-scroll, tool call/result visual pairing, and a11y (semantic markup, aria-live, contrast fixes) - **Operational event persistence**: Runner now pushes `tool_use`, `tool_result`, `error`, and `lifecycle` events to the session messages API via new `OperationalEventWriter` (previously only `user`/`assistant` were persisted) - **Auth simplification**: Removed oauth-proxy and none auth modes; native-sso only via Keycloak OIDC - **User menu**: Initials avatar + dropdown with sign-out in nav header - **OOMKilled detection**: Control plane pod syncer now detects OOMKilled/Error terminated containers - **Runner OOM fix**: Pre-install `mcp-server-fetch` in Dockerfile instead of downloading via `uvx` at runtime (eliminated 15GB memory spike) - **Smart polling**: Restored phase-aware polling — 1s transitioning, 3s active, stopped for terminal sessions, 30s for projects - **Dev experience**: `make dev COMPONENT=ambient-ui` handles port-forwards, Keycloak hostname patching, `.env.local` generation - **Test suite**: 23 files, 227 unit tests in 2.9s (vitest) + Playwright e2e scaffold ## Runner changes (SDD-managed, warn mode) The `.mcp.json` and `Dockerfile` changes fix a critical OOM issue: `uvx mcp-server-fetch` was downloading and installing packages into a temp venv at runtime, spiking memory to 15GB. Now pre-installed at build time with a pinned version (`mcp-server-fetch==2025.4.7`). ## Test plan - [x] `npx vitest run` — 227 tests pass - [x] `npx tsc --noEmit` — zero type errors - [x] Logs tab renders with filter chips, error banner, live tail indicator - [x] Runner pushes operational events visible in Logs tab (requires rebuilt runner image) - [x] SSO login flow works against Kind Keycloak - [x] `make dev COMPONENT=ambient-ui` starts dev server with working auth - [x] Smart polling stops for terminal sessions - [ ] Verify on staging with real agent sessions 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Ambient UI added with SSO login, top-right user menu and sign-out, session event log viewer (filters, live-tail, accessible announcements), and a new /api/me endpoint. * **Bug Fixes** * Better pod crash detection (includes OOMKilled/Error) and increased runner memory. * **Developer Experience** * Local dev improvements: new make targets and automatic port-forwards for Ambient UI and Keycloak. * **Tests** * Added unit and Playwright E2E tests and test coverage scripts. * **Documentation** * Ambient UI development guide and architecture notes added. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 9ad67f7 commit f9c8817

58 files changed

Lines changed: 2500 additions & 203 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,10 @@ KIND_FWD_BACKEND_PORT ?= $(shell echo $$((12000 + $(KIND_PORT_OFFSET))))
101101
KIND_FWD_BACKEND_PORT := $(KIND_FWD_BACKEND_PORT)
102102
KIND_FWD_API_SERVER_PORT ?= $(shell echo $$((13000 + $(KIND_PORT_OFFSET))))
103103
KIND_FWD_API_SERVER_PORT := $(KIND_FWD_API_SERVER_PORT)
104+
KIND_FWD_AMBIENT_UI_PORT ?= $(shell echo $$((14000 + $(KIND_PORT_OFFSET))))
105+
KIND_FWD_AMBIENT_UI_PORT := $(KIND_FWD_AMBIENT_UI_PORT)
106+
KIND_FWD_KEYCLOAK_PORT ?= $(shell echo $$((18000 + $(KIND_PORT_OFFSET))))
107+
KIND_FWD_KEYCLOAK_PORT := $(KIND_FWD_KEYCLOAK_PORT)
104108
# Remote kind host — set to Tailscale IP/hostname of the Linux build machine.
105109
# When set, kubeconfig is rewritten so kubectl/port-forward work from Mac.
106110
KIND_HOST ?=
@@ -675,9 +679,9 @@ preflight: preflight-cluster ## Validate dev environment (cluster tools + option
675679
p=$$(echo "$$piece" | sed 's/^[[:space:]]*//;s/[[:space:]]*$$//'); \
676680
[ -z "$$p" ] && continue; \
677681
case "$$p" in \
678-
frontend) NEED_NODE=1 ;; \
682+
frontend|ambient-ui) NEED_NODE=1 ;; \
679683
backend) NEED_GO=1 ;; \
680-
*) echo "$(COLOR_RED)$(COLOR_RESET) Unknown COMPONENT: $$p (use frontend, backend, or frontend,backend)"; FAILED=1 ;; \
684+
*) echo "$(COLOR_RED)$(COLOR_RESET) Unknown COMPONENT: $$p (use frontend, backend, ambient-ui, or comma-separated)"; FAILED=1 ;; \
681685
esac; \
682686
done; \
683687
fi; \
@@ -753,7 +757,30 @@ dev-env: check-kubectl check-local-context ## Generate components/frontend/.env.
753757
echo "$(COLOR_GREEN)$(COLOR_RESET) Wrote $$ENV_FILE"; \
754758
fi
755759

756-
dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=frontend|backend|frontend,backend for hot-reload
760+
dev-env-ambient-ui: check-kubectl ## Generate components/ambient-ui/.env.local from cluster state
761+
@kubectl config use-context kind-$(KIND_CLUSTER_NAME) >/dev/null 2>&1 || true
762+
@set -e; \
763+
SESSION_SECRET=$$(printf '%s-ambient-ui' '$(KIND_CLUSTER_NAME)' | sha256sum | cut -c1-32); \
764+
ENV_FILE="components/ambient-ui/.env.local"; \
765+
{ \
766+
echo "# Generated by make dev-env-ambient-ui — do not commit"; \
767+
echo "API_SERVER_URL=http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
768+
echo "NEXT_PUBLIC_PREVIEW_ALLOWED_HOSTS=localhost:*,127.0.0.1:*"; \
769+
echo "SSO_ISSUER_URL=http://localhost:$(KIND_FWD_KEYCLOAK_PORT)/realms/ambient-code"; \
770+
echo "SSO_CLIENT_ID=ambient-frontend"; \
771+
echo "SSO_CLIENT_SECRET=dev-secret-do-not-use-in-prod"; \
772+
echo "SSO_REDIRECT_URI=http://localhost:3001/api/auth/sso/callback"; \
773+
echo "SESSION_SECRET=$$SESSION_SECRET"; \
774+
} > "$$ENV_FILE.tmp"; \
775+
if [ -f "$$ENV_FILE" ] && cmp -s "$$ENV_FILE.tmp" "$$ENV_FILE"; then \
776+
rm -f "$$ENV_FILE.tmp"; \
777+
echo "$(COLOR_GREEN)$(COLOR_RESET) $$ENV_FILE unchanged"; \
778+
else \
779+
mv "$$ENV_FILE.tmp" "$$ENV_FILE"; \
780+
echo "$(COLOR_GREEN)$(COLOR_RESET) Wrote $$ENV_FILE"; \
781+
fi
782+
783+
dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=frontend|backend|ambient-ui for hot-reload
757784
@if [ -z "$(COMPONENT)" ]; then $(MAKE) --no-print-directory preflight-cluster; else $(MAKE) --no-print-directory preflight; fi
758785
@set -e; \
759786
if [ "$(CONTAINER_ENGINE)" = "podman" ]; then export KIND_EXPERIMENTAL_PROVIDER=podman; fi; \
@@ -781,10 +808,10 @@ dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=fronten
781808
kubectl config use-context kind-$(KIND_CLUSTER_NAME); \
782809
fi; \
783810
COMP="$(COMPONENT)"; \
784-
HAS_FRONT=0; HAS_BACK=0; \
811+
HAS_FRONT=0; HAS_BACK=0; HAS_AUI=0; \
785812
for piece in $$(echo "$$COMP" | tr ',' ' '); do \
786813
p=$$(echo "$$piece" | sed 's/^[[:space:]]*//;s/[[:space:]]*$$//'); \
787-
case "$$p" in frontend) HAS_FRONT=1 ;; backend) HAS_BACK=1 ;; esac; \
814+
case "$$p" in frontend) HAS_FRONT=1 ;; backend) HAS_BACK=1 ;; ambient-ui) HAS_AUI=1 ;; esac; \
788815
done; \
789816
DEV_LOCAL=0; \
790817
if [ "$$HAS_FRONT" -eq 1 ] && [ "$$HAS_BACK" -eq 1 ]; then DEV_LOCAL=1; \
@@ -829,6 +856,33 @@ dev: ## Local dev: preflight, cluster, dev-env, port-forwards; COMPONENT=fronten
829856
(cd components/frontend && npm run dev) & NPM_PID=$$!; \
830857
trap 'kill $$GO_PID $$NPM_PID 2>/dev/null; cleanup' INT TERM; \
831858
wait $$GO_PID $$NPM_PID; \
859+
elif [ "$$HAS_AUI" -eq 1 ]; then \
860+
echo "$(COLOR_BLUE)$(COLOR_RESET) ambient-ui dev: setting up API server + Keycloak..."; \
861+
pkill -f "port-forward.*ambient-api-server-service" 2>/dev/null || true; \
862+
pkill -f "port-forward.*keycloak-service" 2>/dev/null || true; \
863+
WANT_KC="http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
864+
CUR_KC=$$(kubectl get deployment keycloak -n $(NAMESPACE) -o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="KC_HOSTNAME")].value}' 2>/dev/null); \
865+
if [ "$$CUR_KC" != "$$WANT_KC" ]; then \
866+
echo "$(COLOR_BLUE)$(COLOR_RESET) Patching Keycloak hostname: $$CUR_KC → $$WANT_KC"; \
867+
kubectl set env deployment/keycloak -n $(NAMESPACE) KC_HOSTNAME="$$WANT_KC" >/dev/null 2>&1; \
868+
kubectl rollout status deployment/keycloak -n $(NAMESPACE) --timeout=120s >/dev/null 2>&1 || true; \
869+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak hostname patched"; \
870+
else \
871+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak hostname already correct"; \
872+
fi; \
873+
kubectl port-forward -n $(NAMESPACE) svc/ambient-api-server-service $(KIND_FWD_API_SERVER_PORT):8000 >/tmp/acp-dev-pf-api.log 2>&1 & PF_PIDS="$$PF_PIDS $$!"; \
874+
kubectl port-forward -n $(NAMESPACE) svc/keycloak-service $(KIND_FWD_KEYCLOAK_PORT):8080 >/tmp/acp-dev-pf-keycloak.log 2>&1 & PF_PIDS="$$PF_PIDS $$!"; \
875+
sleep 2; \
876+
echo "$(COLOR_GREEN)$(COLOR_RESET) API server → http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
877+
echo "$(COLOR_GREEN)$(COLOR_RESET) Keycloak → http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
878+
$(MAKE) --no-print-directory dev-env-ambient-ui; \
879+
echo ""; \
880+
echo "$(COLOR_BOLD)Access:$(COLOR_RESET)"; \
881+
echo " Ambient UI: $(COLOR_BLUE)http://localhost:3001$(COLOR_RESET)"; \
882+
echo " API server: http://localhost:$(KIND_FWD_API_SERVER_PORT)"; \
883+
echo " Keycloak: http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"; \
884+
echo ""; \
885+
cd components/ambient-ui && npm run dev; \
832886
fi
833887

834888
##@ Benchmarking
@@ -969,14 +1023,18 @@ kind-login: check-kubectl check-local-context ## Set kubectl context, port-forwa
9691023
kind-port-forward: check-kubectl check-local-context ## Port-forward kind services (for remote Podman)
9701024
@echo "$(COLOR_BOLD)Port forwarding kind services ($(KIND_CLUSTER_NAME))$(COLOR_RESET)"
9711025
@echo ""
972-
@echo " Frontend: http://localhost:$(KIND_FWD_FRONTEND_PORT)"
973-
@echo " Backend: http://localhost:$(KIND_FWD_BACKEND_PORT)"
1026+
@echo " Frontend: http://localhost:$(KIND_FWD_FRONTEND_PORT)"
1027+
@echo " Backend: http://localhost:$(KIND_FWD_BACKEND_PORT)"
1028+
@echo " Ambient UI: http://localhost:$(KIND_FWD_AMBIENT_UI_PORT)"
1029+
@echo " Keycloak: http://localhost:$(KIND_FWD_KEYCLOAK_PORT)"
9741030
@echo ""
9751031
@echo "$(COLOR_YELLOW)Press Ctrl+C to stop$(COLOR_RESET)"
9761032
@echo ""
9771033
@trap 'echo ""; echo "$(COLOR_GREEN)✓$(COLOR_RESET) Port forwarding stopped"; exit 0' INT; \
9781034
(kubectl port-forward -n ambient-code svc/frontend-service $(KIND_FWD_FRONTEND_PORT):3000 >/dev/null 2>&1 &); \
9791035
(kubectl port-forward -n ambient-code svc/backend-service $(KIND_FWD_BACKEND_PORT):8080 >/dev/null 2>&1 &); \
1036+
(kubectl port-forward -n ambient-code svc/ambient-ui-service $(KIND_FWD_AMBIENT_UI_PORT):3000 >/dev/null 2>&1 &); \
1037+
(kubectl port-forward -n ambient-code svc/keycloak-service $(KIND_FWD_KEYCLOAK_PORT):8080 >/dev/null 2>&1 &); \
9801038
wait
9811039

9821040
dev-bootstrap: check-kubectl check-local-context ## Bootstrap developer workspace with API key and integrations
@@ -1169,7 +1227,7 @@ kind-status: check-kind ## Show all kind clusters and their port assignments
11691227
@echo " Cluster: $(KIND_CLUSTER_NAME)"
11701228
@if [ -n "$(KIND_HOST)" ]; then echo " Host: $(KIND_HOST) (remote)"; else echo " Host: localhost"; fi
11711229
@echo " NodePort: $(KIND_HTTP_PORT) (HTTP) / $(KIND_HTTPS_PORT) (HTTPS)"
1172-
@echo " Forward: $(KIND_FWD_FRONTEND_PORT) (frontend) / $(KIND_FWD_BACKEND_PORT) (backend)"
1230+
@echo " Forward: $(KIND_FWD_FRONTEND_PORT) (frontend) / $(KIND_FWD_BACKEND_PORT) (backend) / $(KIND_FWD_KEYCLOAK_PORT) (keycloak)"
11731231
@echo ""
11741232
@CLUSTERS=$$($(if $(filter podman,$(CONTAINER_ENGINE)),KIND_EXPERIMENTAL_PROVIDER=podman) kind get clusters 2>/dev/null); \
11751233
if [ -z "$$CLUSTERS" ]; then \

components/ambient-control-plane/internal/reconciler/kube_reconciler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,7 @@ func (r *SimpleKubeReconciler) ensurePod(ctx context.Context, namespace string,
515515
"resources": map[string]interface{}{
516516
"requests": map[string]interface{}{
517517
"cpu": "500m",
518-
"memory": "512Mi",
518+
"memory": "1Gi",
519519
},
520520
"limits": map[string]interface{}{
521521
"cpu": "2000m",

components/ambient-control-plane/internal/reconciler/pod_sync.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ func (s *PodStatusSyncer) hasContainerCrashLoop(pod *unstructured.Unstructured)
188188
return true
189189
}
190190
}
191+
name, _, _ := unstructured.NestedString(csMap, "name")
192+
terminated, found, _ := unstructured.NestedMap(csMap, "state", "terminated")
193+
if found && name == "ambient-code-runner" {
194+
reason, _, _ := unstructured.NestedString(terminated, "reason")
195+
if reason == "OOMKilled" || reason == "Error" {
196+
return true
197+
}
198+
}
191199
}
192200

193201
initStatuses, found, _ := unstructured.NestedSlice(pod.Object, "status", "initContainerStatuses")

components/ambient-ui/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Ambient UI
2+
3+
Operations console for the Ambient Code Platform. Next.js BFF with OIDC authentication (Keycloak), shadcn/ui components, and Red Hat design system.
4+
5+
## Local Development
6+
7+
Prerequisites: Kind cluster running (`make kind-up`).
8+
9+
```bash
10+
make dev COMPONENT=ambient-ui
11+
```
12+
13+
This single command:
14+
1. Sets kubectl context to the correct Kind cluster
15+
2. Port-forwards the API server and Keycloak
16+
3. Patches Keycloak's `KC_HOSTNAME` so OIDC redirects work locally
17+
4. Generates `.env.local` with correct SSO config
18+
5. Starts the Next.js dev server on `http://localhost:3001`
19+
20+
Login with the Keycloak admin credentials (`admin` / `admin`) or any user configured in the `ambient-code` realm.
21+
22+
### Other useful commands
23+
24+
```bash
25+
make dev-env-ambient-ui # Regenerate .env.local without starting the dev server
26+
make kind-reload-ambient-ui # Rebuild image and redeploy to Kind
27+
make kind-status # Show cluster ports (including Keycloak)
28+
```
29+
30+
## Testing
31+
32+
```bash
33+
npm test # Unit tests (vitest, ~3s)
34+
npm run test:watch # Watch mode
35+
npm run test:coverage # With coverage
36+
37+
# E2E (requires: npm install && npx playwright install chromium)
38+
npm run test:e2e
39+
```
40+
41+
## Architecture
42+
43+
- **BFF pattern**: Next.js server handles OIDC auth, proxies API calls with JWT injection. Browser never sees raw tokens.
44+
- **Port/Adapter**: Domain types in `src/domain/`, ports in `src/ports/`, SDK adapters in `src/adapters/`. Components consume ports, never SDK types.
45+
- **iron-session**: Per-user encrypted cookie sessions for connection context and auth state.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Authentication endpoints', () => {
4+
test('GET /api/me returns unauthenticated when no session cookie', async ({
5+
request,
6+
}) => {
7+
const response = await request.get('/api/me')
8+
9+
expect(response.status()).toBe(200)
10+
const body = await response.json()
11+
expect(body.authenticated).toBe(false)
12+
})
13+
14+
test('GET /api/config returns config shape', async ({ request }) => {
15+
const response = await request.get('/api/config')
16+
17+
expect(response.status()).toBe(200)
18+
const body = await response.json()
19+
expect(body).toHaveProperty('apiServerUrl')
20+
expect(body).toHaveProperty('isCustomContext')
21+
expect(typeof body.apiServerUrl).toBe('string')
22+
expect(typeof body.isCustomContext).toBe('boolean')
23+
})
24+
})
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('Health check endpoint', () => {
4+
test('GET /api/healthz returns 200 with status ok', async ({ request }) => {
5+
const response = await request.get('/api/healthz')
6+
7+
expect(response.status()).toBe(200)
8+
const body = await response.json()
9+
expect(body).toEqual({ status: 'ok' })
10+
})
11+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from '@playwright/test'
2+
3+
test.describe('BFF proxy endpoint', () => {
4+
test('GET /api/ambient/v1/sessions without auth returns 401 or 502', async ({
5+
request,
6+
}) => {
7+
const response = await request.get('/api/ambient/v1/sessions', {
8+
failOnStatusCode: false,
9+
})
10+
11+
// Without auth: 401 (no token). Without backend: 502 (upstream unreachable).
12+
// 404/405 would indicate a broken route — reject those.
13+
expect([401, 502]).toContain(response.status())
14+
})
15+
})

components/ambient-ui/package-lock.json

Lines changed: 64 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/ambient-ui/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
"lint": "eslint",
1010
"test": "vitest run",
1111
"test:watch": "vitest",
12-
"test:coverage": "vitest run --coverage"
12+
"test:coverage": "vitest run --coverage",
13+
"test:e2e": "npx playwright test"
1314
},
1415
"dependencies": {
1516
"@radix-ui/react-avatar": "^1.1.10",
@@ -42,6 +43,7 @@
4243
},
4344
"devDependencies": {
4445
"@eslint/eslintrc": "^3",
46+
"@playwright/test": "^1.52.0",
4547
"@tailwindcss/postcss": "^4",
4648
"@testing-library/jest-dom": "^6.9.1",
4749
"@testing-library/react": "^16.3.2",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { defineConfig, devices } from '@playwright/test'
2+
3+
export default defineConfig({
4+
testDir: './e2e',
5+
fullyParallel: true,
6+
forbidOnly: !!process.env.CI,
7+
retries: process.env.CI ? 1 : 0,
8+
workers: process.env.CI ? 1 : undefined,
9+
reporter: 'list',
10+
use: {
11+
baseURL: 'http://localhost:3001',
12+
trace: 'on-first-retry',
13+
},
14+
projects: [
15+
{
16+
name: 'chromium',
17+
use: { ...devices['Desktop Chrome'] },
18+
},
19+
],
20+
// Start the dev server before running: npm run dev
21+
// webServer is omitted — tests expect an already-running server.
22+
})

0 commit comments

Comments
 (0)