# Phase 4 — Admin Web UI + Dynamic Routing

**Status:** ✅ Complete — 2026-04-26
**Duration:** Weeks 11–12
**Goal:** LiveView operational dashboard; operators can view and change routing rules live.

---

## Deliverable

1. Live pipeline throughput dashboard (messages/sec, error rate, latency P50/P99)
2. Route rule editor — edit, enable/disable routes; changes take effect immediately
3. Adapter health panel — circuit breaker status per adapter
4. Audit log viewer — searchable, paginated
5. DLQ manager — view failed messages, re-queue or discard
6. All data live — no page refresh required

---

## Tasks

### 1. gateway_web — LiveView Application

Complete the `gateway_web` app started in Phase 0.

```elixir
# apps/gateway_web/lib/gateway_web_web/router.ex
pipeline :admin_browser do
  plug :accepts, ["html"]
  plug :fetch_session
  plug :protect_from_forgery
  plug MwAuth.SessionPlug   # validates session JWT, checks admin role
end

scope "/admin", GatewayWebWeb do
  pipe_through [:admin_browser]

  live "/", DashboardLive, :index
  live "/pipeline", PipelineMonitorLive, :index
  live "/routing", RouteEditorLive, :index
  live "/adapters", AdapterHealthLive, :index
  live "/audit", AuditLogLive, :index
  live "/dlq", DlqLive, :index
end
```

### 2. DashboardLive — Pipeline Metrics

Subscribes to telemetry events via `Phoenix.PubSub`. Updates charts in real time.

```elixir
defmodule GatewayWebWeb.DashboardLive do
  use GatewayWebWeb, :live_view

  def mount(_params, _session, socket) do
    if connected?(socket), do: Phoenix.PubSub.subscribe(MwCore.PubSub, "telemetry:pipeline")
    {:ok, assign(socket, metrics: load_initial_metrics())}
  end

  def handle_info({:pipeline_metric, metric}, socket) do
    {:noreply, update(socket, :metrics, &merge_metric(&1, metric))}
  end
end
```

Display:
- Requests/sec per adapter (sparkline)
- Error rate % per adapter
- P50 / P99 latency per adapter
- Active circuit breaker states

### 3. RouteEditorLive — Dynamic Routing Control

```elixir
defmodule GatewayWebWeb.RouteEditorLive do
  use GatewayWebWeb, :live_view

  def mount(_params, _session, socket) do
    rules = MwRouter.RouteTable.all_rules()
    {:ok, assign(socket, rules: rules, editing: nil)}
  end

  def handle_event("save_rule", %{"rule" => params}, socket) do
    case MwRouter.RouteTable.upsert_rule(params) do
      {:ok, rule} ->
        # This broadcasts via PubSub to all nodes, reloading ETS instantly
        {:noreply, assign(socket, rules: MwRouter.RouteTable.all_rules(), editing: nil)}
      {:error, changeset} ->
        {:noreply, assign(socket, errors: changeset.errors)}
    end
  end

  def handle_event("toggle_active", %{"message_type" => type}, socket) do
    MwRouter.RouteTable.toggle_active(type)
    {:noreply, assign(socket, rules: MwRouter.RouteTable.all_rules())}
  end
end
```

Route editor UI shows:
- Table of all routes (message_type → adapter, active toggle, timeout, priority)
- Edit modal — change adapter, timeout, circuit breaker config
- "Disable" button — instantly stops routing that message type (returns 503)
- "Add Route" form
- Last modified by / at

### 4. AdapterHealthLive — Circuit Breaker Status

Polls `:fuse` status for each registered adapter every 5 seconds:

```elixir
defmodule GatewayWebWeb.AdapterHealthLive do
  use GatewayWebWeb, :live_view

  @adapters [AdapterBanking, AdapterDw, AdapterHttp, AdapterFile]
  @poll_ms 5_000

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :poll, @poll_ms)
    {:ok, assign(socket, health: fetch_health())}
  end

  def handle_info(:poll, socket) do
    Process.send_after(self(), :poll, @poll_ms)
    {:noreply, assign(socket, health: fetch_health())}
  end

  defp fetch_health do
    Enum.map(@adapters, fn adapter ->
      {adapter, MwRouter.CircuitBreaker.status(adapter)}
    end)
  end
end
```

Status displayed as: green (closed), yellow (half-open), red (open) with time-until-reset.
"Reset Circuit" button calls `:fuse.reset/1`.

### 5. AuditLogLive — Searchable Audit Trail

```elixir
defmodule GatewayWebWeb.AuditLogLive do
  use GatewayWebWeb, :live_view

  def mount(params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(MwCore.PubSub, "audit:events")
    end
    {:ok, assign(socket, events: load_events(params), filters: default_filters())}
  end

  def handle_info({:new_audit_event, event}, socket) do
    {:noreply, update(socket, :events, &[event | Enum.take(&1, 99)])}
  end
end
```

Features:
- Live feed — new events appear at top without refresh
- Filter by: tenant_id, adapter, status, date range, trace_id
- Click trace_id → deep link to OTel Jaeger trace (configurable URL template)
- Export as CSV (background job)

### 6. DlqLive — Dead Letter Queue Management

```elixir
def handle_event("requeue", %{"id" => id}, socket) do
  entry = InfraQueue.DeadLetterStore.get!(id)
  InfraQueue.FilePipeline.requeue(entry)
  InfraQueue.DeadLetterStore.mark_requeued(id)
  {:noreply, assign(socket, entries: load_dlq())}
end

def handle_event("discard", %{"id" => id}, socket) do
  InfraQueue.DeadLetterStore.discard(id)
  {:noreply, assign(socket, entries: load_dlq())}
end
```

---

## Acceptance Criteria

- [ ] Dashboard shows live metrics updating without page refresh
- [ ] Operator changes adapter module for a route → next request uses new adapter (< 500ms)
- [ ] Operator disables a route → next request returns 503 immediately
- [ ] Circuit breaker status refreshes every 5 seconds
- [ ] Audit log live-appends new events as they are written
- [ ] DLQ re-queue sends failed message back through Broadway pipeline
- [ ] Admin UI is inaccessible without `admin` role (returns 403)
