# Remote Executors UI — Bug Analysis & Fixes

> **Date:** 15 March 2026  
> **Status:** ✅ All Fixes Implemented  
> **Scope:** Executor settings crash, monitoring page empty, UI alignment gaps, backend wiring issues

---

## Table of Contents

1. [Executive Summary](#1-executive-summary)
2. [Issue 1: Executor Settings Page Crash (JS Error)](#2-issue-1-executor-settings-page-crash)
3. [Issue 2: Monitoring Page Shows No Executors](#3-issue-2-monitoring-page-shows-no-executors)
4. [Issue 3: Gateway Routing — Split Backend Problem](#4-issue-3-gateway-routing--split-backend-problem)
5. [Issue 4: UI Alignment & Layout Inconsistencies](#5-issue-4-ui-alignment--layout-inconsistencies)
6. [Issue 5: History Page — React Fragment Key Warning](#6-issue-5-history-page--react-fragment-key-warning)
7. [Issue 6: Gateway Status API Response Mismatch](#7-issue-6-gateway-status-api-response-mismatch)
8. [Issue 7: SSE Stream — Missing Auth Token](#8-issue-7-sse-stream--missing-auth-token)
9. [Issue 8: Executor Detail — Missing `activated_at`/`activated_by`](#9-issue-8-executor-detail--missing-gateway-status-fields)
10. [Fix Implementation Plan](#10-fix-implementation-plan)

---

## 1. Executive Summary

The Remote Executors UI has **three categories of issues**:

| Category | Severity | Issues | Status |
|----------|----------|--------|--------|
| **Crash Bugs** | 🔴 Critical | Executor settings page crashes with JS error (Issue #1) | ✅ Fixed |
| **Backend Wiring** | 🔴 Critical | Monitoring, logs, SSE stream, execution history all return 404 (Issues #2, #3) | ✅ Fixed |
| **UI Polish** | 🟡 Medium | Layout gaps, alignment inconsistencies, React key warnings (Issues #4, #5, #6, #7, #8) | ✅ Fixed |

**Root Cause Summary:**

The UI was built against a complete API surface that spans **two backend services** (Platform Admin + qRunX), but the **API Gateway only routes executor paths to Platform Admin**. The qRunX endpoints for monitoring, SSE stream, execution logs, and project-level execution history are unreachable — they return 404 because the gateway sends the requests to Platform Admin instead of qRunX.

The settings page crash is caused by a secondary issue: the `useExecutor` hook returns data with snake_case fields, but the `MetricsTab` component attempts to call `.toFixed()` on potentially undefined/null values from the `metrics` object without null-checking.

---

## 2. Issue 1: Executor Settings Page Crash

### Symptom
Clicking "Settings" on an executor card (or navigating to `/projects/{shortId}/executors/{executorId}`) crashes the UI with a JavaScript error.

### Root Cause
**File:** `app/(project)/projects/[shortId]/executors/[executorId]/page.tsx`

The `MetricsTab` component accesses `metrics?.cpu_percent` and calls `.toFixed()` without guarding against non-number values. When the API returns `metrics` as `null` or with unexpected types, this throws:

```tsx
// Line in MetricsTab — causes crash when val is string/undefined
{typeof val === "number" && !Number.isInteger(val) ? val.toFixed(1) : val}
```

But the real crash is more fundamental — the `OverviewTab` component has this type annotation:

```tsx
function OverviewTab({
  executor,
  ...
}: {
  executor: NonNullable<ReturnType<typeof useExecutor>["data"]>
  ...
})
```

`ReturnType<typeof useExecutor>["data"]` resolves to the type from `getExecutor()` which returns `Promise<Executor>`. However, `useExecutor` returns `UseQueryResult<Executor, Error>`, meaning `.data` is `Executor | undefined`. `NonNullable<>` should strip `undefined`, but the issue is that `useExecutor` might return successfully with an object whose nested fields (`metrics`, `system_info`, `capabilities`, `labels`) are `null` or `undefined`.

**Specific crash scenarios:**

1. **`metrics` is null** → `MetricsTab` renders "No metrics available" (OK)
2. **`metrics` is `{}` (empty object)** → `Object.keys(metrics).length === 0` → "No metrics available" (OK)
3. **`metrics` has values like `{ cpu_percent: "45.2" }` (string from API)** → `Number("45.2")` works but edge cases with non-numeric strings crash `.toFixed()` → `NaN.toFixed()` doesn't crash but displays `NaN`
4. **`labels` is null from API** → `Object.entries(executor.labels)` throws `TypeError: Cannot convert undefined or null to object` — **THIS IS THE CRASH**

The `Executor` interface defines `labels: Record<string, string>` (non-nullable), but the backend `ExecutorController.kt` returns a `Map<String, Any?>` from `executorService.getExecutor()`. If the database stores `labels` as `NULL` or `{}`, the JSON response may serialize it as `null`, violating the UI's type assumption.

### Fix

**File:** `app/(project)/projects/[shortId]/executors/[executorId]/page.tsx`

```tsx
// In OverviewTab, guard against null labels:
{Object.entries(executor.labels ?? {}).map(([k, v]) => (
  // ...
))}

// In MetricsTab, guard against non-number metrics:
const val = Number(metrics[m.key])
if (isNaN(val)) return null // skip rendering
```

**File:** `app/(project)/projects/[shortId]/executors/page.tsx`

```tsx
// In executor card, guard against null labels:
const labelEntries = Object.entries(executor.labels ?? {}).slice(0, 3)
```

**Defensive normalization in API client layer (recommended):**

```tsx
// lib/api/executors.ts — after getExecutor response, normalize:
export async function getExecutor(projectId: string, executorId: string): Promise<Executor> {
  const raw = await api.get<Executor>(`${basePath(projectId)}/${executorId}`)
  return {
    ...raw,
    labels: raw.labels ?? {},
    capabilities: raw.capabilities ?? [],
    system_info: raw.system_info ?? null,
    metrics: raw.metrics ?? null,
  }
}
```

---

## 3. Issue 2: Monitoring Page Shows No Executors

### Symptom
The monitoring page (`/projects/{shortId}/executors/monitoring`) loads but shows "No executors registered" and all aggregate counters at 0, even though executors are visible on the main list page.

### Root Cause

The monitoring page calls `useMonitoringSummary(project.id)` which hits:

```
GET /api/v1/projects/{projectId}/executors/monitoring/summary
```

This endpoint exists in **qRunX** (`backend/runtime/api/executors.py` line ~796), NOT in Platform Admin Service. However, the API Gateway routes **all** `/api/v1/projects/**` to Platform Admin:

```kotlin
routes.route("platform-admin") { r ->
    r.path(
        "/api/v1/projects/**",
        // ...
    ).uri(platformAdminUri)
}
```

Platform Admin has no `/monitoring/summary` endpoint, so it returns **404**. The UI interprets this as "no data" and shows the empty state.

### Fix

Add a specific gateway route for executor monitoring/stream/logs endpoints to qRunX, placed **BEFORE** the `platform-admin` catch-all route. See [Issue #3](#4-issue-3-gateway-routing--split-backend-problem) for the complete fix.

---

## 4. Issue 3: Gateway Routing — Split Backend Problem

### Analysis

The executor management API is **split across two services**:

| Endpoint | Backend Service | Status |
|----------|----------------|--------|
| `GET /api/v1/projects/{id}/executors` | Platform Admin ✅ | Works — routed by `platform-admin` catch-all |
| `GET /api/v1/projects/{id}/executors/{eid}` | Platform Admin ✅ | Works |
| `PATCH /api/v1/projects/{id}/executors/{eid}` | Platform Admin ✅ | Works |
| `DELETE /api/v1/projects/{id}/executors/{eid}` | Platform Admin ✅ | Works |
| `POST /api/v1/projects/{id}/executors/{eid}/drain` | Platform Admin ✅ | Works |
| `GET /api/v1/projects/{id}/executors/api-keys` | Platform Admin ✅ | Works |
| `POST /api/v1/projects/{id}/executors/api-keys` | Platform Admin ✅ | Works |
| `POST /api/v1/projects/{id}/executors/api-keys/{kid}/revoke` | Platform Admin ✅ | Works |
| `POST /api/v1/projects/{id}/executors/api-keys/{kid}/rotate` | Platform Admin ✅ | Works |
| `GET /api/v1/projects/{id}/executors/routing-rules` | qRunX ❌ | **404** — routed to Platform Admin |
| `POST /api/v1/projects/{id}/executors/routing-rules` | qRunX ❌ | **404** |
| `PATCH /api/v1/projects/{id}/executors/routing-rules/{rid}` | qRunX ❌ | **404** |
| `DELETE /api/v1/projects/{id}/executors/routing-rules/{rid}` | qRunX ❌ | **404** |
| `GET /api/v1/projects/{id}/executors/{eid}/logs` | qRunX ❌ | **404** |
| `GET /api/v1/projects/{id}/executors/executions/{eid}` | qRunX ❌ | **404** |
| `GET /api/v1/projects/{id}/executors/executions` | qRunX ❌ | **404** |
| `GET /api/v1/projects/{id}/executors/monitoring/summary` | qRunX ❌ | **404** |
| `GET /api/v1/projects/{id}/executors/stream` | qRunX ❌ | **404** |
| `GET /api/v1/admin/projects/{id}/executor-gateway/status` | Platform Admin ✅ | Works (via `/api/v1/admin/**` route) |
| `POST /api/v1/admin/projects/{id}/executor-gateway/activate` | Platform Admin ✅ | Works |
| `POST /api/v1/admin/projects/{id}/executor-gateway/deactivate` | Platform Admin ✅ | Works |
| `PUT /api/v1/admin/projects/{id}/executor-gateway/default-execution-location` | Platform Admin ✅ | Works |
| `GET /api/v1/executors/binary/latest` | ❌ Not Implemented | No backend exists yet |
| `GET /api/v1/executors/binary/download` | ❌ Not Implemented | No backend exists yet |

### Impact

| UI Page | Status | Reason |
|---------|--------|--------|
| **Executor List** (`/executors`) | ✅ Works | Uses Platform Admin endpoints |
| **Executor Settings** (`/executors/{id}`) | ✅ Fixed | Null-guards added for `labels`/`capabilities`; API client normalizes; gateway routes executor logs to qRunX |
| **Monitoring** (`/executors/monitoring`) | ✅ Fixed | Gateway routes `/monitoring/summary` to qRunX; SSE stream auth fixed |
| **API Keys** (`/executors/api-keys`) | ✅ Works | Uses Platform Admin endpoints |
| **Routing Rules** (`/executors/routing-rules`) | ✅ Fixed | Gateway routes routing-rules to qRunX |
| **Execution History** (`/executors/history`) | ✅ Fixed | Gateway routes `/executions` to qRunX; Fragment key warning fixed |
| **Setup Wizard** (`/executors/setup`) | ⚠️ Partial | API key creation works; binary download not implemented (no backend) |

### Fix

**File:** `PROJECTS/qraptor-api-gateway/src/main/java/ai/qraptor/gateway/config/GatewayRoutesConfig.kt`

Add these routes **BEFORE** the `platform-admin` catch-all:

```kotlin
// Executor Runtime APIs — route to qRunX for monitoring, logs, stream, routing rules, executions
// IMPORTANT: Must be BEFORE platform-admin to avoid being swallowed by /api/v1/projects/**
routes.route("executor-runtime-monitoring") { r ->
    r.path(
        "/api/v1/projects/*/executors/monitoring/**",
        "/api/v1/projects/*/executors/executions",
        "/api/v1/projects/*/executors/executions/**",
        "/api/v1/projects/*/executors/routing-rules",
        "/api/v1/projects/*/executors/routing-rules/**",
        "/api/v1/projects/*/executors/*/logs"
    )
    .filters { f ->
        f.removeRequestHeader("X-Execution-Context")
        f.addRequestHeader("X-Execution-Context", "preview")
    }
    .uri(qrunxUri)
}

// Executor SSE Stream — needs special headers for Server-Sent Events
routes.route("executor-runtime-stream") { r ->
    r.path("/api/v1/projects/*/executors/stream")
        .filters { f ->
            f.addResponseHeader("X-Accel-Buffering", "no")
            f.addResponseHeader("Cache-Control", "no-cache, no-store")
            f.addResponseHeader("Connection", "keep-alive")
            f.removeRequestHeader("X-Execution-Context")
            f.addRequestHeader("X-Execution-Context", "preview")
        }
        .metadata("response-timeout", 300000)  // 5 minutes for SSE
        .metadata("connect-timeout", 5000)
        .uri(qrunxUri)
}
```

**Note:** The order of routes matters. These more-specific routes must appear before the generic `platform-admin` route that matches `/api/v1/projects/**`.

---

## 5. Issue 4: UI Alignment & Layout Inconsistencies

### Analysis

Comparing executor pages with other project pages (agents, overview, data), there are several spacing/layout inconsistencies:

| Issue | File(s) | Description |
|-------|---------|-------------|
| **Inconsistent max-width** | `[executorId]/page.tsx` uses `max-w-4xl`; `setup/page.tsx` uses `max-w-2xl`; `api-keys/page.tsx` uses `max-w-4xl`; main list page has `p-6` with no max-width | The main executor list page has no `max-w-*` constraint, making its content stretch across the full sidebar-to-edge width. Other project pages (overview, agents) use similar `p-6` full-width, so the list page is actually consistent. But setup, detail, API keys pages have `max-w-*` that creates jarring width changes when navigating. |
| **No page header consistency** | Various | The main list page has a full header bar with multiple buttons. Sub-pages (API keys, routing rules, history) have back-arrow + title. The monitoring page also has back-arrow + title. This is fine for hierarchy but the header heights aren't aligned. |
| **Large empty gaps** | `monitoring/page.tsx` | The 6-column stat grid (`grid-cols-2 md:grid-cols-4 lg:grid-cols-6`) at narrow widths can leave stats awkwardly small. At wide widths, 6 stat cards stretch too thin. |
| **Button positioning** | `page.tsx` (list) | The header has 4 link buttons (Monitoring, API Keys, Routing, Add Executor) all in a row. On medium screens this overflows and wraps poorly. No responsive wrap handling. |
| **Missing page wrapper class** | All executor pages | Other project pages inherit the `bg-[#0f1316]` background from the layout's `<main>`. Executor pages add their own `p-6` directly. This is actually correct and consistent, but some sub-pages add extra `mx-auto` centering that causes visual jumps. |
| **Gateway URL display** | `page.tsx` (list) | The gateway URL bar and execution location selector are each in their own full-width container, creating visual noise. These could be condensed. |

### Specific Layout Fixes

#### 5a. Main List Page — Header Button Overflow

**File:** `app/(project)/projects/[shortId]/executors/page.tsx`

The header buttons wrap poorly on tablets. Fix:

```tsx
// Change:
<div className="flex items-center gap-2">

// To:
<div className="flex items-center gap-2 flex-wrap">
```

#### 5b. Remove max-width on sub-pages for consistency

**File:** `app/(project)/projects/[shortId]/executors/[executorId]/page.tsx`

The executor detail page uses `max-w-4xl` but the main list page is full-width. This causes a width "jump" when clicking into an executor. Options:

- **Option A:** Remove `max-w-4xl` from detail page (match list page)
- **Option B:** Add `max-w-7xl mx-auto` to list page (match other pages)

**Recommendation:** Option A — remove `max-w-4xl` from detail page since the list page and monitoring page are full-width.

#### 5c. Monitoring Stats Grid

**File:** `app/(project)/projects/[shortId]/executors/monitoring/page.tsx`

Change from `lg:grid-cols-6` to `lg:grid-cols-4 xl:grid-cols-6` for better mid-size screen behavior:

```tsx
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
```

#### 5d. Consistent padding on setup page

**File:** `app/(project)/projects/[shortId]/executors/setup/page.tsx`

The `max-w-2xl` is quite narrow for the tabbed install section. Change to `max-w-3xl` for better tab visibility.

---

## 6. Issue 5: History Page — React Fragment Key Warning

### Symptom
React console warning: "Each child in a list should have a unique `key` prop."

### Root Cause

**File:** `app/(project)/projects/[shortId]/executors/history/page.tsx`  

The expandable table rows use bare `<>` fragments:

```tsx
{logs.map((log) => {
  return (
    <>                          {/* ← No key on fragment */}
      <tr key={log.id}>...</tr>
      {isExpanded && <tr key={`${log.id}-detail`}>...</tr>}
    </>
  )
})}
```

React fragments returned from `.map()` must have keys.

### Fix

```tsx
{logs.map((log) => {
  return (
    <Fragment key={log.id}>     {/* ← Add key via named Fragment */}
      <tr>...</tr>
      {isExpanded && <tr>...</tr>}
    </Fragment>
  )
})}
```

Import `Fragment` from `react`:
```tsx
import { useState, Fragment } from "react"
```

---

## 7. Issue 6: Gateway Status API Response Mismatch

### Analysis

The UI type `ExecutorGatewayStatus` expects:

```typescript
export interface ExecutorGatewayStatus {
  active: boolean
  activated_at?: string | null       // ← snake_case
  activated_by?: string | null       // ← snake_case
  defaultExecutionLocation?: string  // ← camelCase
}
```

The backend `ExecutorGatewayController.kt` `/status` endpoint returns:

```kotlin
mapOf<String, Any?>(
    "active" to tuple.t1,
    "projectId" to projectId.toString(),
    "subscriptionId" to subscriptionId.toString(),
    "defaultExecutionLocation" to tuple.t2
)
```

**Issues:**
1. `activated_at` and `activated_by` are never returned by the backend — the UI type defines them but they're always `undefined`
2. The backend returns `projectId` and `subscriptionId` which the UI type doesn't define (harmless — they're ignored)
3. Mixed casing: `defaultExecutionLocation` is camelCase while the executor model uses snake_case (`max_concurrency`, `last_heartbeat_at`)

### Impact
Low — the UI doesn't actually display `activated_at` or `activated_by` currently. The `active` and `defaultExecutionLocation` fields work correctly.

### Fix (Backend)
Add `activated_at` and `activated_by` to the status response if needed, or remove unused fields from the UI type.

---

## 8. Issue 7: SSE Stream — Missing Auth Token

### Symptom
The SSE stream (monitoring page live updates) shows "Disconnected" and never connects.

### Root Cause

**File:** `lib/hooks/use-executors.ts`

The `useExecutorStream` hook creates a native `EventSource`:

```typescript
const url = getExecutorStreamUrl(projectId)
const es = new EventSource(url)
```

The `getExecutorStreamUrl` function builds:

```typescript
export function getExecutorStreamUrl(projectId: string): string {
  const base = process.env.NEXT_PUBLIC_API_BASE_URL || process.env.NEXT_PUBLIC_QRUNX_URL || ""
  return `${base}${basePath(projectId)}/stream`
}
```

**Problems:**

1. **No auth token:** Native `EventSource` does not support custom headers. The gateway requires `Authorization: Bearer <token>` but `EventSource` only sends cookies. If the auth token is in a header (not a cookie), SSE will get 401 Unauthorized.

2. **Wrong base URL:** The function falls back to `NEXT_PUBLIC_QRUNX_URL` (direct qRunX URL), but in production the SSE request must go through the gateway (same-origin). If `NEXT_PUBLIC_API_BASE_URL` is set, it will construct the correct URL, but the request still lacks auth.

3. **Gateway routing:** Even if auth works, the SSE endpoint (`/api/v1/projects/{id}/executors/stream`) is caught by the `platform-admin` route, not qRunX. (See Issue #3.)

### Fix

**Option A: Use `fetch` + `ReadableStream` instead of `EventSource`**

Replace `EventSource` with a `fetch`-based SSE reader that can include auth headers:

```typescript
const response = await fetch(url, {
  headers: {
    'Authorization': `Bearer ${token}`,
    'Accept': 'text/event-stream',
  }
})
const reader = response.body?.getReader()
// Parse SSE events from reader
```

**Option B: Pass token as query parameter**

Modify the stream URL to include the token:
```typescript
const url = `${base}${basePath(projectId)}/stream?token=${token}`
```

Then the backend extracts the token from query params instead of headers. This is less secure (token in URL logs) but simpler.

**Option C: Use `EventSourcePolyfill` library**

A polyfill that supports custom headers:
```typescript
import { EventSourcePolyfill } from 'event-source-polyfill'
const es = new EventSourcePolyfill(url, {
  headers: { 'Authorization': `Bearer ${token}` }
})
```

**Recommendation:** Option A (fetch-based SSE) for no new dependencies, or Option C for simplest code change.

---

## 9. Issue 8: Executor Detail — Missing Gateway Status Fields

### Analysis

The `ExecutorGatewayStatus` interface in the UI type has fields that the backend never returns:

| Field | In UI Type | Returned by Backend | Used in UI |
|-------|-----------|-------------------|----------|
| `active` | ✅ | ✅ | ✅ Used in gateway activation check |
| `activated_at` | ✅ | ❌ Not returned | ❌ Not used anywhere |
| `activated_by` | ✅ | ❌ Not returned | ❌ Not used anywhere |
| `defaultExecutionLocation` | ✅ | ✅ | ✅ Used in location selector |
| `projectId` | ❌ | ✅ | ❌ |
| `subscriptionId` | ❌ | ✅ | ❌ |

### Fix
Clean up the `ExecutorGatewayStatus` interface to match reality:

```typescript
export interface ExecutorGatewayStatus {
  active: boolean
  defaultExecutionLocation?: string
}
```

Or add `activated_at`/`activated_by` to the backend if needed for display.

---

## 10. Fix Implementation Plan

### Priority Order

| # | Fix | Severity | Effort | Files Changed | Status |
|---|-----|----------|--------|---------------|--------|
| **F1** | Gateway routing — add qRunX executor routes | 🔴 Critical | Medium | `GatewayRoutesConfig.kt` | ✅ Done (15 Mar 2026) |
| **F2** | Null-guard `labels`/`capabilities` in detail page | 🔴 Critical | Small | `[executorId]/page.tsx`, `page.tsx` (list) | ✅ Done (15 Mar 2026) |
| **F3** | Null-guard in API client (normalize response) | 🔴 Critical | Small | `lib/api/executors.ts` | ✅ Done (15 Mar 2026) |
| **F4** | SSE stream auth token handling | 🟡 Medium | Medium | `lib/hooks/use-executors.ts`, `lib/api/executors.ts` | ✅ Done (15 Mar 2026) |
| **F5** | React Fragment key warning | 🟡 Medium | Small | `history/page.tsx` | ✅ Done (15 Mar 2026) |
| **F6** | UI alignment — header button wrap | 🟡 Medium | Small | `executors/page.tsx` | ✅ Done (15 Mar 2026) |
| **F7** | UI alignment — remove max-w-4xl from detail | 🟡 Medium | Small | `[executorId]/page.tsx` | ✅ Done (15 Mar 2026) |
| **F8** | Monitoring stats grid responsive fix | 🟢 Low | Small | `monitoring/page.tsx` | ✅ Done (15 Mar 2026) |
| **F9** | Setup page width increase | 🟢 Low | Small | `setup/page.tsx` | ✅ Done (15 Mar 2026) |
| **F10** | Clean up `ExecutorGatewayStatus` type | 🟢 Low | Small | `lib/api/executors.ts` | ✅ Done (15 Mar 2026) |

### Fix F1: Gateway Routing (Critical)

**File:** `PROJECTS/qraptor-api-gateway/src/main/java/ai/qraptor/gateway/config/GatewayRoutesConfig.kt`

**Insert BEFORE the `platform-admin` route block:**

```kotlin
// ============================================================
// Executor Runtime APIs — qRunX handles monitoring, logs, 
// stream, routing rules. Must be BEFORE platform-admin.
// ============================================================
routes.route("executor-monitoring") { r ->
    r.path("/api/v1/projects/*/executors/monitoring/**")
        .filters { f ->
            f.removeRequestHeader("X-Execution-Context")
            f.addRequestHeader("X-Execution-Context", "preview")
        }
        .uri(qrunxUri)
}

routes.route("executor-stream") { r ->
    r.path("/api/v1/projects/*/executors/stream")
        .filters { f ->
            f.addResponseHeader("X-Accel-Buffering", "no")
            f.addResponseHeader("Cache-Control", "no-cache, no-store")
            f.addResponseHeader("Connection", "keep-alive")
            f.removeRequestHeader("X-Execution-Context")
            f.addRequestHeader("X-Execution-Context", "preview")
        }
        .metadata("response-timeout", 300000)
        .metadata("connect-timeout", 5000)
        .uri(qrunxUri)
}

routes.route("executor-executions") { r ->
    r.path(
        "/api/v1/projects/*/executors/executions",
        "/api/v1/projects/*/executors/executions/**"
    )
    .filters { f ->
        f.removeRequestHeader("X-Execution-Context")
        f.addRequestHeader("X-Execution-Context", "preview")
    }
    .uri(qrunxUri)
}

routes.route("executor-routing-rules") { r ->
    r.path(
        "/api/v1/projects/*/executors/routing-rules",
        "/api/v1/projects/*/executors/routing-rules/**"
    )
    .filters { f ->
        f.removeRequestHeader("X-Execution-Context")
        f.addRequestHeader("X-Execution-Context", "preview")
    }
    .uri(qrunxUri)
}

routes.route("executor-logs") { r ->
    r.path("/api/v1/projects/*/executors/*/logs")
        .filters { f ->
            f.removeRequestHeader("X-Execution-Context")
            f.addRequestHeader("X-Execution-Context", "preview")
        }
        .uri(qrunxUri)
}
```

### Fix F2: Null-guard labels/capabilities in detail page

**File:** `app/(project)/projects/[shortId]/executors/[executorId]/page.tsx`

```tsx
// OverviewTab — Labels section
{Object.entries(executor.labels ?? {}).map(([k, v]) => (
  // ...
))}
{Object.keys(executor.labels ?? {}).length === 0 && (
  <span className="text-xs text-[#eaeae0]/40">No labels</span>
)}

// OverviewTab — Capabilities section
{(executor.capabilities ?? []).map((cap) => (
  // ...
))}
{(executor.capabilities ?? []).length === 0 && (
  <span className="text-xs text-[#eaeae0]/40">No capabilities reported</span>
)}
```

**File:** `app/(project)/projects/[shortId]/executors/page.tsx` (list page)

```tsx
// In executor card labels:
const labelEntries = Object.entries(executor.labels ?? {}).slice(0, 3)
```

### Fix F3: API client normalization

**File:** `lib/api/executors.ts`

Add normalization to `getExecutor` and `listExecutors`:

```typescript
function normalizeExecutor(raw: Executor): Executor {
  return {
    ...raw,
    labels: raw.labels ?? {},
    capabilities: raw.capabilities ?? [],
    system_info: raw.system_info ?? null,
    metrics: raw.metrics ?? null,
  }
}

export async function getExecutor(projectId: string, executorId: string): Promise<Executor> {
  const raw = await api.get<Executor>(`${basePath(projectId)}/${executorId}`)
  return normalizeExecutor(raw)
}

export async function listExecutors(
  projectId: string,
  params?: { page?: number; size?: number; status?: string }
): Promise<ExecutorListResponse> {
  // ... existing code ...
  const result = await api.get<ExecutorListResponse>(basePath(projectId), query)
  return {
    ...result,
    executors: result.executors.map(normalizeExecutor),
  }
}
```

### Fix F4: SSE stream auth

**File:** `lib/hooks/use-executors.ts`

Replace `EventSource` with custom `fetch`-based SSE (example using EventSourcePolyfill or fetch):

```typescript
// Option: use fetch-based SSE
const connect = useCallback(async () => {
  if (!projectId || !enabled) return
  
  const url = getExecutorStreamUrl(projectId)
  const token = getAuthToken() // from auth context
  
  try {
    const response = await fetch(url, {
      headers: {
        'Authorization': `Bearer ${token}`,
        'Accept': 'text/event-stream',
      },
    })
    
    if (!response.ok || !response.body) {
      setIsConnected(false)
      setTimeout(connect, 5000)
      return
    }
    
    setIsConnected(true)
    const reader = response.body.getReader()
    const decoder = new TextDecoder()
    let buffer = ''
    
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      
      buffer += decoder.decode(value, { stream: true })
      const lines = buffer.split('\n')
      buffer = lines.pop() || ''
      
      for (const line of lines) {
        if (line.startsWith('data: ')) {
          try {
            const data = JSON.parse(line.slice(6))
            // process event...
          } catch { /* ignore */ }
        }
      }
    }
  } catch {
    setIsConnected(false)
    setTimeout(connect, 5000)
  }
}, [projectId, enabled])
```

### Fix F5: Fragment key

**File:** `app/(project)/projects/[shortId]/executors/history/page.tsx`

```tsx
import { useState, Fragment } from "react"

// In the map:
{logs.map((log) => {
  return (
    <Fragment key={log.id}>
      <tr>...</tr>
      {isExpanded && <tr>...</tr>}
    </Fragment>
  )
})}
```

### Fix F6–F9: UI alignment

| Fix | File | Change |
|-----|------|--------|
| F6 | `executors/page.tsx` | Add `flex-wrap` to header button container |
| F7 | `[executorId]/page.tsx` | Remove `max-w-4xl` from root div |
| F8 | `monitoring/page.tsx` | Change grid to `grid-cols-2 md:grid-cols-3 lg:grid-cols-6` |
| F9 | `setup/page.tsx` | Change `max-w-2xl` to `max-w-3xl` |

### Fix F10: Type cleanup

**File:** `lib/api/executors.ts`

```typescript
export interface ExecutorGatewayStatus {
  active: boolean
  defaultExecutionLocation?: string
}
```

---

## Appendix: Complete File Impact Matrix

| File | Changes | Fix IDs |
|------|---------|---------|
| `PROJECTS/qraptor-api-gateway/src/.../GatewayRoutesConfig.kt` | Add 5 new routes for executor runtime APIs | F1 |
| `PROJECTS/qraptor-ui/lib/api/executors.ts` | Add `normalizeExecutor()`, clean up `ExecutorGatewayStatus` type | F3, F10 |
| `PROJECTS/qraptor-ui/lib/hooks/use-executors.ts` | Replace `EventSource` with fetch-based SSE | F4 |
| `PROJECTS/qraptor-ui/app/.../executors/page.tsx` | Add `flex-wrap`, null-guard `labels` | F2, F6 |
| `PROJECTS/qraptor-ui/app/.../executors/[executorId]/page.tsx` | Null-guard `labels`/`capabilities`, remove `max-w-4xl` | F2, F7 |
| `PROJECTS/qraptor-ui/app/.../executors/monitoring/page.tsx` | Adjust stats grid | F8 |
| `PROJECTS/qraptor-ui/app/.../executors/history/page.tsx` | Add `Fragment` key | F5 |
| `PROJECTS/qraptor-ui/app/.../executors/setup/page.tsx` | Widen max-width | F9 |

---

## Appendix: Duplicate Endpoint Concern

Both Platform Admin **and** qRunX have executor CRUD endpoints at the same path (`/api/v1/projects/{id}/executors`). This is a potential maintenance issue:

- **Platform Admin:** `ExecutorController.kt` — list, get, update, delete, drain
- **qRunX:** `executors.py` — list, get, update, delete, drain + monitoring + logs + stream + routing rules + executions

Currently the gateway routes CRUD to Platform Admin and we're adding monitoring/logs/stream to qRunX. Long-term, consider **consolidating all executor endpoints to one service** (likely Platform Admin, since it's the source of truth for executor registration).

The qRunX endpoints for monitoring, stream, and execution logs make sense in qRunX because they involve real-time runtime data (heartbeats, execution results). The API keys and gateway config make sense in Platform Admin. Routing rules could go either way.

**Recommendation for future work:** Keep the current split but clearly document which service owns which endpoints. Add the path-level routing in the gateway as documented in Fix F1.
